177 lines
7.5 KiB
Python
177 lines
7.5 KiB
Python
"""Tests for the portfolio accounting ledger (Task 3)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from app.backtest_events import DIVIDEND_PAYMENT_LAG_DAYS
|
|
from app.portfolio_ledger import (
|
|
FEE_RATE,
|
|
LOT_SIZE,
|
|
PortfolioLedger,
|
|
PortfolioLedgerError,
|
|
)
|
|
|
|
|
|
SHARES = LOT_SIZE # 100
|
|
|
|
|
|
class BuySellTest(unittest.TestCase):
|
|
def test_buy_100_lots_and_average_cost(self):
|
|
ledger = PortfolioLedger(1_000_000)
|
|
ledger.buy("A", SHARES, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
pos = ledger.position("A")
|
|
assert pos is not None
|
|
self.assertEqual(pos.qty, SHARES)
|
|
self.assertEqual(pos.average_cost, 100.0)
|
|
# fee 0.3% of 100*100=10,000 -> 30
|
|
expected_cost = 10_000 * FEE_RATE
|
|
self.assertAlmostEqual(ledger.state.fees, expected_cost)
|
|
self.assertAlmostEqual(
|
|
ledger.state.cash, 1_000_000 - 10_000 - expected_cost, places=2
|
|
)
|
|
|
|
def test_average_cost_after_second_buy_at_higher_price(self):
|
|
ledger = PortfolioLedger(1_000_000)
|
|
ledger.buy("A", SHARES, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
ledger.buy("A", SHARES, 200.0, date="2026-02-05", signal_date="2026-02-01")
|
|
pos = ledger.position("A")
|
|
assert pos is not None
|
|
self.assertEqual(pos.qty, 2 * SHARES)
|
|
# avg = (100*100 + 200*100) / 200 = 150
|
|
self.assertAlmostEqual(pos.average_cost, 150.0, places=2)
|
|
|
|
def test_realized_pnl_uses_average_cost(self):
|
|
ledger = PortfolioLedger(1_000_000)
|
|
ledger.buy("A", SHARES, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
# sell at 110 -> avg cost 100; gross realized = 11000 - 10000 = 1000
|
|
trade = ledger.sell("A", SHARES, 110.0, date="2026-03-05", signal_date="2026-03-01")
|
|
self.assertAlmostEqual(trade.realized_pnl, 1_000.0, places=2)
|
|
self.assertAlmostEqual(ledger.state.realized_pnl, 1_000.0, places=2)
|
|
# sell fee = 0.3% * 11000 = 33 tracked separately
|
|
self.assertAlmostEqual(trade.fees, 33.0, places=2)
|
|
# full exit drops the position
|
|
self.assertIsNone(ledger.position("A"))
|
|
|
|
def test_buy_requires_lot_multiple(self):
|
|
ledger = PortfolioLedger(1_000_000)
|
|
with self.assertRaises(PortfolioLedgerError):
|
|
ledger.buy("A", 50, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
|
|
def test_buy_requires_enough_cash(self):
|
|
ledger = PortfolioLedger(10_000)
|
|
# 100 shares * 500 = 50,000 + fee > 10,000
|
|
with self.assertRaises(PortfolioLedgerError):
|
|
ledger.buy("A", SHARES, 500.0, date="2026-01-05", signal_date="2026-01-01")
|
|
|
|
def test_sell_more_than_held_rejected(self):
|
|
ledger = PortfolioLedger(1_000_000)
|
|
ledger.buy("A", SHARES, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
with self.assertRaises(PortfolioLedgerError):
|
|
ledger.sell("A", 2 * SHARES, 100.0, date="2026-03-05", signal_date="2026-03-01")
|
|
|
|
|
|
class DividendTest(unittest.TestCase):
|
|
def test_entitlement_uses_shares_held_before_ex_date(self):
|
|
ledger = PortfolioLedger(1_000_000)
|
|
ledger.buy("A", 2 * SHARES, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
ledger.record_dividend_entitlement("A", "2026-03-10", 1.5)
|
|
self.assertEqual(len(ledger.state.receivables), 1)
|
|
r = ledger.state.receivables[0]
|
|
self.assertEqual(r.qty_entitled, 2 * SHARES)
|
|
self.assertAlmostEqual(r.amount, 2 * SHARES * 1.5, places=2)
|
|
# payment = ex_date + 30 calendar days
|
|
self.assertEqual(r.assumed_payment_date, "2026-04-09")
|
|
self.assertEqual(r.timing_method, "ex_date_plus_30d")
|
|
# NOT yet cash
|
|
self.assertAlmostEqual(ledger.state.dividend_cash_received, 0.0)
|
|
|
|
def test_no_entitlement_when_no_shares_held(self):
|
|
ledger = PortfolioLedger(1_000_000)
|
|
ledger.record_dividend_entitlement("A", "2026-03-10", 1.5)
|
|
self.assertEqual(len(ledger.state.receivables), 0)
|
|
|
|
def test_payment_credits_cash_at_ex_date_plus_30(self):
|
|
ledger = PortfolioLedger(1_000_000)
|
|
ledger.buy("A", 2 * SHARES, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
ledger.record_dividend_entitlement("A", "2026-03-10", 1.5)
|
|
cash_before = ledger.state.cash
|
|
# before payment date -> no credit
|
|
ledger.pay_due_dividends("2026-04-08")
|
|
self.assertAlmostEqual(ledger.state.cash, cash_before, places=2)
|
|
# on/after payment date -> credited
|
|
ledger.pay_due_dividends("2026-04-09")
|
|
self.assertAlmostEqual(
|
|
ledger.state.cash, cash_before + 2 * SHARES * 1.5, places=2
|
|
)
|
|
self.assertAlmostEqual(
|
|
ledger.state.dividend_cash_received, 2 * SHARES * 1.5, places=2
|
|
)
|
|
self.assertEqual(len(ledger.state.receivables), 0)
|
|
|
|
def test_receivable_is_not_spendable_before_payment(self):
|
|
ledger = PortfolioLedger(50_000)
|
|
ledger.buy("A", SHARES, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
ledger.record_dividend_entitlement("A", "2026-03-10", 1.5)
|
|
# dividend receivable = 150, but cash is only 10,030-ish; can't buy 100*200
|
|
with self.assertRaises(PortfolioLedgerError):
|
|
ledger.buy("B", SHARES, 400.0, date="2026-03-15", signal_date="2026-03-10")
|
|
# after payment, cash grows and can fund the buy
|
|
ledger.pay_due_dividends("2026-04-09")
|
|
ledger.buy("B", SHARES, 10.0, date="2026-04-10", signal_date="2026-04-09")
|
|
self.assertEqual(ledger.qty("B"), SHARES)
|
|
|
|
|
|
class ReconcileTest(unittest.TestCase):
|
|
def test_equity_matches_market_value_plus_cash(self):
|
|
ledger = PortfolioLedger(1_000_000)
|
|
ledger.buy("A", SHARES, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
prices = {"A": 110.0}
|
|
self.assertAlmostEqual(
|
|
ledger.equity(prices),
|
|
ledger.state.cash + 100 * 110.0,
|
|
places=2,
|
|
)
|
|
|
|
def test_reconciled_balanced_with_sell(self):
|
|
ledger = PortfolioLedger(100_000)
|
|
ledger.buy("A", SHARES, 50.0, date="2026-01-05", signal_date="2026-01-01")
|
|
ledger.sell("A", SHARES, 60.0, date="2026-02-05", signal_date="2026-02-01")
|
|
prices = {}
|
|
r = ledger.reconcile(prices)
|
|
self.assertTrue(r["balanced"])
|
|
# notional return equals realized pnl - fees
|
|
self.assertAlmostEqual(
|
|
r["notional_return"], r["realized_trading_pnl"] - r["transaction_costs"],
|
|
places=1,
|
|
)
|
|
|
|
def test_reconciled_balanced_with_holding_and_dividend(self):
|
|
ledger = PortfolioLedger(100_000)
|
|
ledger.buy("A", SHARES, 50.0, date="2026-01-05", signal_date="2026-01-01")
|
|
ledger.record_dividend_entitlement("A", "2026-03-10", 1.0)
|
|
ledger.pay_due_dividends("2026-04-09")
|
|
prices = {"A": 55.0}
|
|
r = ledger.reconcile(prices)
|
|
self.assertTrue(r["balanced"])
|
|
# equity = cash(+div) + 100*55
|
|
expected_equity = ledger.state.cash + 100 * 55.0
|
|
self.assertAlmostEqual(r["equity"], expected_equity, places=2)
|
|
|
|
|
|
class FeeConfigTest(unittest.TestCase):
|
|
def test_custom_fee_rate(self):
|
|
ledger = PortfolioLedger(1_000_000, fee_rate=0.0)
|
|
ledger.buy("A", SHARES, 100.0, date="2026-01-05", signal_date="2026-01-01")
|
|
self.assertEqual(ledger.state.fees, 0.0)
|
|
|
|
def test_invalid_fee_rate_rejected(self):
|
|
with self.assertRaises(PortfolioLedgerError):
|
|
PortfolioLedger(1000, fee_rate=1.0)
|
|
with self.assertRaises(PortfolioLedgerError):
|
|
PortfolioLedger(1000, fee_rate=-0.1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|