"""Cost-aware FIFO pairing uses actual ledger cash flows, including both fees.""" from __future__ import annotations import pytest from quant_engine.execution import ExecutionConfig, simulate_daily_ledger_with_audit from quant_engine.trade_pairing import pair_ledger_trades def test_loss_after_both_fees_is_not_a_win(): ledger = simulate_daily_ledger_with_audit( [("d1", {"A": 0.1}), ("d2", {})], [("d1", {"A": 10}), ("d2", {"A": 9})], [("d1", {"A": 10}), ("d2", {"A": 9})], 1000, ExecutionConfig(commission_bps=100, stamp_tax_bps=200, slippage_bps=0, min_trade_amount=0), ) pairing = pair_ledger_trades(ledger) assert len(pairing.closed_lots) == 1 assert pairing.closed_lots[0].quantity == 10 assert pairing.closed_lots[0].cost == 101 assert pairing.closed_lots[0].net_proceeds == pytest.approx(87.3) assert pairing.realized_net_pnl == pytest.approx(-13.7) assert pairing.win_rate == 0 assert pairing.open_lots == () assert ledger.nav_series.tolist() == pytest.approx([999, 986.3]) def test_last_day_multiple_fills_each_pay_once_and_match_nav(): ledger = simulate_daily_ledger_with_audit( [("d2", {"A": 0.25, "B": 0.5})], [("d2", {"A": 10, "B": 20})], [("d1", {"A": 10, "B": 20}), ("d2", {"A": 10, "B": 20})], 1000, ExecutionConfig(commission_bps=100, stamp_tax_bps=200, slippage_bps=0, min_trade_amount=0), ) assert ledger.nav_series.tolist() == pytest.approx([1000, 992.5]) assert ledger.trades_frame["fee"].tolist() == [2.5, 5.0] pairing = pair_ledger_trades(ledger) assert len(pairing.open_lots) == 2 assert pairing.closed_lots == () assert pairing.realized_net_pnl == 0 assert pairing.win_rate is None def test_partial_fifo_sales_allocate_entry_cost_and_keep_unclosed_lot_out_of_win_rate(): ledger = simulate_daily_ledger_with_audit( [("d1", {"A": 0.2}), ("d2", {"A": 0.1}), ("d3", {})], [("d1", {"A": 10}), ("d2", {"A": 10}), ("d3", {"A": 10})], [("d1", {"A": 10}), ("d2", {"A": 10}), ("d3", {"A": 10})], 1000, ExecutionConfig(commission_bps=100, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0), ) pairing = pair_ledger_trades(ledger) assert len(pairing.matches) == 2 assert len(pairing.closed_lots) == 1 assert pairing.closed_lots[0].quantity == 20 assert pairing.closed_lots[0].cost == 202 assert pairing.closed_lots[0].net_proceeds == pytest.approx(198) assert pairing.realized_net_pnl == pytest.approx(-4) assert pairing.win_rate == 0 def test_same_day_sell_and_buy_are_different_lots_with_no_duplicate_fees(): ledger = simulate_daily_ledger_with_audit( [("d1", {"A": 0.5}), ("d2", {"B": 0.5})], [("d1", {"A": 10, "B": 10}), ("d2", {"A": 10, "B": 10})], [("d1", {"A": 10, "B": 10}), ("d2", {"A": 10, "B": 10})], 1000, ExecutionConfig(commission_bps=100, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0), ) assert ledger.nav_series.tolist() == pytest.approx([995, 985.025]) pairing = pair_ledger_trades(ledger) assert pairing.closed_lots[0].asset == "A" assert pairing.closed_lots[0].net_pnl == pytest.approx(-10) assert pairing.open_lots[0].asset == "B" def test_small_fractional_holding_is_not_destroyed_after_partial_sale(): ledger = simulate_daily_ledger_with_audit( [("d1", {"A": 0.5}), ("d2", {"A": 0.25})], [("d1", {"A": 1e9}), ("d2", {"A": 1e9})], [("d1", {"A": 1e9}), ("d2", {"A": 1e9})], 1000, ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0), ) assert ledger.nav_series.tolist() == [1000, 1000] assert ledger.positions[-1].holdings["A"] == 2.5e-7 pairing = pair_ledger_trades(ledger) assert pairing.open_lots[0].quantity == 2.5e-7 assert pairing.open_lots[0].remaining_cost == 250 def test_real_tiny_remaining_lot_is_not_treated_as_a_completed_trade(): ledger = simulate_daily_ledger_with_audit( [("d1", {"A": 1}), ("d2", {"A": 1e-13})], [("d1", {"A": 1}), ("d2", {"A": 1})], [("d1", {"A": 1}), ("d2", {"A": 1})], 1e12, ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0), ) pairing = pair_ledger_trades(ledger) assert pairing.closed_lots == () assert pairing.win_rate is None assert pairing.open_lots[0].quantity == ledger.positions[-1].holdings["A"] assert pairing.open_lots[0].remaining_cost == pytest.approx(ledger.positions[-1].holdings["A"]) @pytest.mark.parametrize("price", [3, 11, 13]) def test_complete_exit_closes_all_accumulated_lots_without_rounding_residue(price): prices = [(date, {"A": price}) for date in ("d1", "d2", "d3", "d4")] ledger = simulate_daily_ledger_with_audit( [("d1", {"A": 0.1}), ("d2", {"A": 0.2}), ("d3", {"A": 0.3}), ("d4", {})], prices, prices, 1000, ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0), ) pairing = pair_ledger_trades(ledger) assert ledger.positions[-1].holdings == {} assert pairing.open_lots == () assert len(pairing.closed_lots) == 3 assert sum(lot.cost for lot in pairing.closed_lots) == pytest.approx(300) assert sum(match.net_proceeds for match in pairing.matches) == pytest.approx(300) assert pairing.realized_net_pnl == pytest.approx(0) assert pairing.win_rate == 0