128 lines
5.4 KiB
Python
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
|