Files
quant_engine/tests/test_trade_pairing.py
T
2026-10-04 11:10:01 +08:00

128 lines
5.4 KiB
Python

"""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