From 8af061cc18b9c4e39d2a074efb1363cff21dc7bb Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:47:39 +0800 Subject: [PATCH] fix: value PIT positions from total book cost --- README.md | 10 ++ src/quant_execution/_fixed.py | 13 ++ src/quant_execution/ledger.py | 195 ++++++++++++++---------- tests/test_pit_cost_basis.py | 272 ++++++++++++++++++++++++++++++++++ 4 files changed, 412 insertions(+), 78 deletions(-) create mode 100644 tests/test_pit_cost_basis.py diff --git a/README.md b/README.md index f8560a1..5f1b00c 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,16 @@ verifies the actual payment. Manifest `trusted_verification_scope` lists only th `market_admission_certified` remains false because this module does not assess complete market admission. +Under `EVIDENCED_PIT`, `AccountSnapshot.cost_basis` is a reporting average rounded +half-even to the instrument price field's scale. Mixed fills can have a repeating +average or a finite average finer than that scale. Valuation, risk and settlement +use exact posted total cost rather than that display price. Partial closes allocate +the proportional total book cost once, half-even at `money_scale`; the unallocated +residue stays with the position, and the final close removes all remaining cost. +Realized P&L is the exact residual of posted proceeds and allocated cost. This book +allocation convention does not relax dividend payment-policy or PIT FX precision +requirements. Fees remain separate expense postings. + Manifest `1.1.0` dividend exports seal one ledger snapshot before writing. Opening positions, external cash, marks, ordinary ledger events, PIT FX observations, valuations, and dividend phases share one ordered operation sequence. Replay applies those business facts in order and uses final diff --git a/src/quant_execution/_fixed.py b/src/quant_execution/_fixed.py index f9788f4..aea01a2 100644 --- a/src/quant_execution/_fixed.py +++ b/src/quant_execution/_fixed.py @@ -114,6 +114,19 @@ def multiply_decimal_exact(*values: Decimal | FixedPoint | int) -> Decimal: return fraction_decimal_exact(result) +def fixed_fraction_half_even(value: Fraction, scale: int) -> FixedPoint: + """Round a rational book allocation/report value once, independently of context.""" + if not isinstance(value, Fraction): + raise ValidationError("value must be a Fraction") + FixedPoint(0, scale) # Validate the bounded scale before exponentiation. + scaled = value * 10**scale + units, remainder = divmod(abs(scaled.numerator), scaled.denominator) + doubled = remainder * 2 + if doubled > scaled.denominator or (doubled == scaled.denominator and units % 2): + units += 1 + return FixedPoint(-units if scaled < 0 else units, scale) + + def floor_to_scale(value: Decimal, scale: int) -> FixedPoint: return fixed(value, scale, rounding=ROUND_DOWN) diff --git a/src/quant_execution/ledger.py b/src/quant_execution/ledger.py index 4b8ceeb..7dd15eb 100644 --- a/src/quant_execution/ledger.py +++ b/src/quant_execution/ledger.py @@ -34,7 +34,7 @@ decimal, decimal_fraction, fixed, - fraction_decimal_exact, + fixed_fraction_half_even, multiply_decimal_exact, sum_decimal_exact, ) @@ -840,16 +840,12 @@ def risk_balances( initial_margin = Decimal(0) for instrument_id, quantity in self._positions.items(): spec = self._spec(instrument_id) - average = self._average_cost(instrument_id) - mark = self._mark_price(instrument_id, fallback=average) - multiplier = decimal(spec.contract_multiplier) + notional, pnl = self._position_valuation(instrument_id, quantity) if self._is_derivative(spec): nav = add_decimal_exact( nav, self._convert_for_valuation( - self._product_for_valuation( - self._difference_for_valuation(mark, average), quantity, multiplier - ), + pnl, spec.settlement_currency, event_time, ), @@ -858,7 +854,7 @@ def risk_balances( initial_margin, self._convert_for_valuation( self._product_for_valuation( - self._product_for_valuation(mark, quantity, multiplier).copy_abs(), + notional.copy_abs(), _meta_decimal(spec, "initial_margin_rate"), ), spec.settlement_currency, @@ -869,7 +865,7 @@ def risk_balances( nav = add_decimal_exact( nav, self._convert_for_valuation( - self._product_for_valuation(mark, quantity, multiplier), + notional, spec.settlement_currency, event_time, ), @@ -909,7 +905,7 @@ def portfolio_risk_snapshot(self, event_time: datetime) -> PortfolioRiskSnapshot raise ValidationError( f"InstrumentSpec metadata {key!r} is required for risk snapshot" ) - absolute_notional = abs(local_notional) + absolute_notional = local_notional.copy_abs() position_initial = self._convert_for_valuation( self._product_for_valuation( absolute_notional, _meta_decimal(spec, "initial_margin_rate") @@ -1092,18 +1088,12 @@ def liquidation_required(self, event_time: datetime | None = None) -> bool: maintenance_margin = Decimal(0) for instrument_id, quantity in self._positions.items(): spec = self._spec(instrument_id) - average = self._average_cost(instrument_id) - mark = self._mark_price(instrument_id, fallback=average) - multiplier = decimal(spec.contract_multiplier) + notional, pnl = self._position_valuation(instrument_id, quantity) if self._is_derivative(spec): nav = add_decimal_exact( nav, self._convert_for_valuation( - self._product_for_valuation( - self._difference_for_valuation(mark, average), - quantity, - multiplier, - ), + pnl, spec.settlement_currency, at, ), @@ -1112,7 +1102,7 @@ def liquidation_required(self, event_time: datetime | None = None) -> bool: maintenance_margin, self._convert_for_valuation( self._product_for_valuation( - self._product_for_valuation(mark, quantity, multiplier).copy_abs(), + notional.copy_abs(), _meta_decimal(spec, "maintenance_margin_rate"), ), spec.settlement_currency, @@ -1123,7 +1113,7 @@ def liquidation_required(self, event_time: datetime | None = None) -> bool: nav = add_decimal_exact( nav, self._convert_for_valuation( - self._product_for_valuation(mark, quantity, multiplier), + notional, spec.settlement_currency, at, ), @@ -1475,11 +1465,7 @@ def settlement_from_market(self, event: StatusEvent) -> Settlement | None: if quantity == 0: return None settlement_price = self._mark_price(event.instrument_id) - amount = ( - (settlement_price - self._average_cost(event.instrument_id)) - * quantity - * decimal(spec.contract_multiplier) - ) + _, amount = self._position_valuation(event.instrument_id, quantity, mark=settlement_price) return Settlement( settlement_id=_identifier("settlement", event.event_id, self.account_id), account_id=self.account_id, @@ -1533,11 +1519,7 @@ def snapshot(self, event_time: datetime | None = None) -> AccountSnapshot: self._convert_for_valuation(realized_value, spec.settlement_currency, at), self.money_scale, ) - mark = self._mark_price(instrument_id, fallback=average) - multiplier = decimal(spec.contract_multiplier) - pnl = self._product_for_valuation( - self._difference_for_valuation(mark, average), quantity, multiplier - ) + notional, pnl = self._position_valuation(instrument_id, quantity) unrealized[instrument_id] = self._fixed_for_valuation( self._convert_for_valuation(pnl, spec.settlement_currency, at), self.money_scale, @@ -1546,7 +1528,7 @@ def snapshot(self, event_time: datetime | None = None) -> AccountSnapshot: nav = add_decimal_exact( nav, self._convert_for_valuation(pnl, spec.settlement_currency, at) ) - notional = self._product_for_valuation(mark, quantity, multiplier).copy_abs() + notional = notional.copy_abs() initial_margin = add_decimal_exact( initial_margin, self._convert_for_valuation( @@ -1571,7 +1553,7 @@ def snapshot(self, event_time: datetime | None = None) -> AccountSnapshot: nav = add_decimal_exact( nav, self._convert_for_valuation( - self._product_for_valuation(mark, quantity, multiplier), + notional, spec.settlement_currency, at, ), @@ -1607,16 +1589,8 @@ def assert_nav_residual(self, snapshot: AccountSnapshot) -> None: for instrument_id, quantity_fp in snapshot.positions.items(): spec = self._spec(instrument_id) quantity = decimal(quantity_fp) - mark = self._mark_price(instrument_id, fallback=self._average_cost(instrument_id)) - multiplier = decimal(spec.contract_multiplier) - if self._is_derivative(spec): - component = self._product_for_valuation( - self._difference_for_valuation(mark, self._average_cost(instrument_id)), - quantity, - multiplier, - ) - else: - component = self._product_for_valuation(mark, quantity, multiplier) + notional, pnl = self._position_valuation(instrument_id, quantity) + component = pnl if self._is_derivative(spec) else notional expected = add_decimal_exact( expected, self._convert_for_valuation(component, spec.settlement_currency, at) ) @@ -1810,30 +1784,47 @@ def _fill_transaction(self, fill_event: Fill) -> LedgerTransaction: if fill_event.account_id != self.account_id: raise ValidationError("fill account differs from ledger account") spec = self._spec(fill_event.instrument_id) + pit = self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT quantity = decimal(fill_event.quantity) - signed_quantity = quantity if fill_event.side is Side.BUY else -quantity + signed_quantity = quantity if fill_event.side is Side.BUY else quantity.copy_negate() old_quantity = self._positions.get(fill_event.instrument_id, Decimal(0)) multiplier = decimal(spec.contract_multiplier) price = decimal(fill_event.price) - notional = quantity * price * multiplier + notional = self._product_for_valuation(quantity, price, multiplier) close_quantity = ( - min(abs(old_quantity), quantity) - if old_quantity and old_quantity * signed_quantity < 0 + min(old_quantity.copy_abs(), quantity) + if old_quantity and (old_quantity > 0) != (signed_quantity > 0) else Decimal(0) ) derivative = fill_event.instrument_id in self._derivative_instruments average = ( self._average_cost(fill_event.instrument_id) - if close_quantity or (not derivative and fill_event.side is Side.SELL) + if not pit and (close_quantity or (not derivative and fill_event.side is Side.SELL)) + else Decimal(0) + ) + allocated_cost = ( + self._allocated_position_cost(fill_event.instrument_id, close_quantity) + if pit and close_quantity else Decimal(0) ) realized = Decimal(0) if close_quantity: - realized = ( - (price - average) * close_quantity * multiplier - if old_quantity > 0 - else (average - price) * close_quantity * multiplier - ) + if pit: + close_notional = decimal( + fixed( + multiply_decimal_exact(price, close_quantity, multiplier), self.money_scale + ) + ) + realized = add_decimal_exact( + close_notional if old_quantity > 0 else close_notional.copy_negate(), + allocated_cost.copy_negate(), + ) + else: + realized = ( + (price - average) * close_quantity * multiplier + if old_quantity > 0 + else (average - price) * close_quantity * multiplier + ) postings: list[Posting] = [] if derivative: if realized: @@ -1843,24 +1834,32 @@ def _fill_transaction(self, fill_event: Fill) -> LedgerTransaction: self._posting( "income:realized_pnl", spec.settlement_currency, - -realized, + realized.copy_negate(), instrument_id=fill_event.instrument_id, ), ] ) old_cost = self._position_cost(fill_event.instrument_id, derivative=True) - new_quantity = old_quantity + signed_quantity - if old_quantity == 0 or old_quantity * signed_quantity > 0: - new_cost = old_cost + signed_quantity * price * multiplier + new_quantity = self._difference_for_valuation( + old_quantity, signed_quantity.copy_negate() + ) + if old_quantity == 0 or (old_quantity > 0) == (signed_quantity > 0): + new_cost = self._difference_for_valuation( + old_cost, + self._product_for_valuation(signed_quantity, price, multiplier).copy_negate(), + ) elif new_quantity == 0: new_cost = Decimal(0) - elif old_quantity * new_quantity > 0: - new_cost = (average * abs(new_quantity) * multiplier) * ( - Decimal(1) if new_quantity > 0 else Decimal(-1) + elif (old_quantity > 0) == (new_quantity > 0): + new_cost = ( + add_decimal_exact(old_cost, allocated_cost.copy_negate()) + if pit + else (average * abs(new_quantity) * multiplier) + * (Decimal(1) if new_quantity > 0 else Decimal(-1)) ) else: - new_cost = new_quantity * price * multiplier - cost_delta = new_cost - old_cost + new_cost = self._product_for_valuation(new_quantity, price, multiplier) + cost_delta = self._difference_for_valuation(new_cost, old_cost) postings.extend( [ self._posting( @@ -1872,13 +1871,13 @@ def _fill_transaction(self, fill_event: Fill) -> LedgerTransaction: self._posting( "memo:position_cost_counter", spec.settlement_currency, - -cost_delta, + cost_delta.copy_negate(), instrument_id=fill_event.instrument_id, ), ] ) else: - if old_quantity + signed_quantity < 0: + if old_quantity < quantity and fill_event.side is Side.SELL: raise ValidationError("cash asset fill would create a short position") if fill_event.side is Side.BUY: cash = self._accounts.get( @@ -1888,7 +1887,9 @@ def _fill_transaction(self, fill_event: Fill) -> LedgerTransaction: raise ValidationError("cash asset fill would create negative cash") postings.extend( [ - self._posting("assets:cash", spec.settlement_currency, -notional), + self._posting( + "assets:cash", spec.settlement_currency, notional.copy_negate() + ), self._posting( "assets:position_cost", spec.settlement_currency, @@ -1902,20 +1903,24 @@ def _fill_transaction(self, fill_event: Fill) -> LedgerTransaction: # their difference can leave a one-unit imbalance for fractional # equity fills. P&L is the exact residual of the posted amounts. notional = decimal(fixed(notional, self.money_scale)) - cost_removed = decimal(fixed(average * quantity * multiplier, self.money_scale)) + cost_removed = ( + allocated_cost + if pit + else decimal(fixed(average * quantity * multiplier, self.money_scale)) + ) postings.extend( [ self._posting("assets:cash", spec.settlement_currency, notional), self._posting( "assets:position_cost", spec.settlement_currency, - -cost_removed, + cost_removed.copy_negate(), instrument_id=fill_event.instrument_id, ), self._posting( "income:realized_pnl", spec.settlement_currency, - -(notional - cost_removed), + self._difference_for_valuation(notional, cost_removed).copy_negate(), instrument_id=fill_event.instrument_id, ), ] @@ -1935,7 +1940,7 @@ def _fill_transaction(self, fill_event: Fill) -> LedgerTransaction: spec.settlement_currency, Decimal(0), instrument_id=fill_event.instrument_id, - quantity_delta=-signed_quantity, + quantity_delta=signed_quantity.copy_negate(), quantity_scale=fill_event.quantity.scale, ), ] @@ -1992,16 +1997,15 @@ def _settlement_transaction(self, event: Settlement) -> LedgerTransaction: quantity = self._positions.get(event.instrument_id, Decimal(0)) multiplier = decimal(spec.contract_multiplier) settlement_price = self._settlement_price(event) - average = self._average_cost(event.instrument_id) - expected = (settlement_price - average) * quantity * multiplier + _, expected = self._position_valuation(event.instrument_id, quantity, mark=settlement_price) rounded_expected = fixed(expected, self.money_scale).to_decimal() if decimal(event.amount) != rounded_expected: raise ValidationError( "daily_mark amount differs from mark-to-market PnL at settlement_price" ) old_cost = self._position_cost(event.instrument_id, derivative=True) - new_cost = quantity * settlement_price * multiplier - cost_delta = new_cost - old_cost + new_cost = self._product_for_valuation(quantity, settlement_price, multiplier) + cost_delta = self._difference_for_valuation(new_cost, old_cost) amount = decimal(event.amount) return self._make_transaction( event_type=LedgerEventType.SETTLEMENT, @@ -2013,7 +2017,7 @@ def _settlement_transaction(self, event: Settlement) -> LedgerTransaction: self._posting( "income:settlement:daily_mark", event.currency, - -amount, + amount.copy_negate(), instrument_id=event.instrument_id, ), self._posting( @@ -2025,7 +2029,7 @@ def _settlement_transaction(self, event: Settlement) -> LedgerTransaction: self._posting( "memo:position_cost_counter", event.currency, - -cost_delta, + cost_delta.copy_negate(), instrument_id=event.instrument_id, ), ), @@ -2344,13 +2348,48 @@ def _average_cost(self, instrument_id: str) -> Decimal: spec = self._spec(instrument_id) cost = self._position_cost(instrument_id, derivative=self._is_derivative(spec)) if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT: - return fraction_decimal_exact( - decimal_fraction(cost.copy_abs()) - / decimal_fraction(quantity.copy_abs()) - / decimal_fraction(spec.contract_multiplier) + # A reporting price, never an input to PIT valuation or cost removal. + return decimal( + fixed_fraction_half_even( + decimal_fraction(cost.copy_abs()) + / decimal_fraction(quantity.copy_abs()) + / decimal_fraction(spec.contract_multiplier), + spec.price_tick.scale, + ) ) return abs(cost) / (abs(quantity) * decimal(spec.contract_multiplier)) + def _position_valuation( + self, instrument_id: str, quantity: Decimal, *, mark: Decimal | None = None + ) -> tuple[Decimal, Decimal]: + """Return signed notional and P&L without rounding a PIT unit cost.""" + spec = self._spec(instrument_id) + multiplier = decimal(spec.contract_multiplier) + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT: + cost = self._position_cost(instrument_id, derivative=self._is_derivative(spec)) + if mark is None and instrument_id in self._marks: + mark = self._marks[instrument_id][0] + # Preserve the existing unmarked-at-book-cost valuation convention, + # using its exact total rather than a rounded implied price. + notional = cost if mark is None else multiply_decimal_exact(mark, quantity, multiplier) + return notional, add_decimal_exact(notional, cost.copy_negate()) + average = self._average_cost(instrument_id) + if mark is None: + mark = self._mark_price(instrument_id, fallback=average) + return mark * quantity * multiplier, (mark - average) * quantity * multiplier + + def _allocated_position_cost(self, instrument_id: str, quantity: Decimal) -> Decimal: + """Allocate the posted cost once; full closure consumes the entire residue.""" + spec = self._spec(instrument_id) + cost = self._position_cost(instrument_id, derivative=self._is_derivative(spec)) + held = self._positions[instrument_id].copy_abs() + return decimal( + fixed_fraction_half_even( + decimal_fraction(cost) * decimal_fraction(quantity) / decimal_fraction(held), + self.money_scale, + ) + ) + def _realized(self, instrument_id: str) -> Decimal: spec = self._spec(instrument_id) credit = self._accounts.get( diff --git a/tests/test_pit_cost_basis.py b/tests/test_pit_cost_basis.py new file mode 100644 index 0000000..10d86ab --- /dev/null +++ b/tests/test_pit_cost_basis.py @@ -0,0 +1,272 @@ +"""PIT book cost must not depend on a representable per-unit average.""" + +from dataclasses import replace +from datetime import timedelta +from decimal import Decimal, Inexact, Rounded, localcontext +from fractions import Fraction + +import pytest +from conftest import T0, event_fields, fp +from quant_data_kit import FixedPoint, StatusEvent +from quant_data_kit.exceptions import ValidationError +from test_hong_kong import instrument, schedule +from test_ledger import PERP, STOCK, fill, mark, perp_spec, stock_spec + +from quant_execution import Settlement, Side +from quant_execution._fixed import fixed_fraction_half_even +from quant_execution.artifacts import export_dividend_run, replay_dividend_run +from quant_execution.dividends import DividendExecutionMode, FxValuationMode +from quant_execution.hong_kong import HKDailyExecution +from quant_execution.ledger import ExactAccountLedger + + +def account(*, derivative=False, money_scale=8): + instrument_spec = ( + replace(perp_spec(), settlement_currency="USD", quote_currency="USD") + if derivative + else stock_spec() + ) + return ExactAccountLedger( + account_id="account", + base_currency=instrument_spec.settlement_currency, + instruments={instrument_spec.instrument_id: instrument_spec}, + initial_cash={instrument_spec.settlement_currency: fp(1000000)}, + money_scale=money_scale, + opened_at=T0, + dividend_execution_mode=DividendExecutionMode.SCENARIO_ONLY, + fx_valuation_mode=FxValuationMode.EVIDENCED_PIT, + ) + + +def book_cost(ledger, symbol): + return sum( + ( + p.amount.to_decimal() + for tx in ledger.transactions + for p in tx.postings + if p.instrument_id == symbol + and p.ledger_account in {"assets:position_cost", "memo:position_cost"} + ), + Decimal(0), + ) + + +@pytest.mark.parametrize("second_price,reported", [("101", "100.67"), ("100.01", "100.01")]) +def test_mixed_lots_report_average_but_value_exact_total_cost(second_price, reported): + ledger = account() + ledger.apply(fill("a", STOCK, Side.BUY, "1", "100", seconds=1)) + ledger.apply(fill("b", STOCK, Side.BUY, "2", second_price, seconds=2)) + unmarked = ledger.snapshot() + assert unmarked.cost_basis[STOCK] == fp(reported) + assert unmarked.nav.to_decimal() == 1000000 + assert unmarked.unrealized_pnl[STOCK].units == 0 + assert ledger.risk_balances(T0 + timedelta(seconds=2))[2] == 1000000 + ledger.mark(mark(STOCK, "102", 3)) + snapshot = ledger.snapshot() + expected = Decimal(306) - book_cost(ledger, STOCK) + assert snapshot.unrealized_pnl[STOCK].to_decimal() == expected + assert snapshot.nav.to_decimal() == 1000000 + expected + assert ledger.portfolio_risk_snapshot(snapshot.event_time).nav == snapshot.nav + assert not ledger.liquidation_required() + + +@pytest.mark.parametrize("second_price,reported", [("100.01", "100.00"), ("100.03", "100.02")]) +def test_finite_subtick_average_rounds_half_even_for_display(second_price, reported): + ledger = account() + ledger.apply(fill("a", STOCK, Side.BUY, "1", "100", seconds=1)) + ledger.apply(fill("b", STOCK, Side.BUY, "1", second_price, seconds=2)) + assert ledger.snapshot().cost_basis[STOCK] == fp(reported) + assert ledger.snapshot().nav.to_decimal() == 1000000 + + +def test_hk_4300_shares_partial_and_final_sale_keep_book_cost_and_t2(tmp_path): + broker = HKDailyExecution( + {"00700": instrument()}, + initial_cash=fp(1000000), + opened_at=T0, + fees=schedule(), + settlement_days=[ + T0.date(), + (T0 + timedelta(days=3)).date(), + (T0 + timedelta(days=4)).date(), + ], + dividend_execution_mode=DividendExecutionMode.SCENARIO_ONLY, + fx_valuation_mode=FxValuationMode.EVIDENCED_PIT, + ) + fees = Decimal(0) + for n, (side, quantity, price) in enumerate( + [ + (Side.BUY, 100, "100"), + (Side.BUY, 4200, "101"), + (Side.SELL, 100, "102"), + (Side.SELL, 4200, "102"), + ] + ): + at = T0 + timedelta(seconds=n) + broker.mark("00700", fp(price), at) + _, charges = broker.execute( + order_id=str(n), symbol="00700", quantity=quantity, side=side, price=fp(price), at=at + ) + fees += sum((v.to_decimal() for v in charges.values()), Decimal(0)) + snapshot = broker.ledger.snapshot() + if n == 1: + assert snapshot.positions["00700"].to_decimal() == 4300 + assert snapshot.cost_basis["00700"] == fp("100.98") + if n == 2: + assert book_cost(broker.ledger, "00700") == Decimal("424102.32558140") + assert snapshot.realized_pnl["00700"].to_decimal() == Decimal("102.32558140") + assert snapshot.unrealized_pnl["00700"].to_decimal() == Decimal("4297.67441860") + assert book_cost(broker.ledger, "00700") == 0 + assert snapshot.realized_pnl["00700"].to_decimal() == 4400 + assert snapshot.nav.to_decimal() == 1004400 - fees + assert broker.available_cash() < broker.ledger.cash_balance("HKD") + broker.settle_end_of_day((T0 + timedelta(days=4)).date()) + assert broker.available_cash() == broker.ledger.cash_balance("HKD") + for tx in broker.ledger.transactions: + assert sum((p.amount.to_decimal() for p in tx.postings), Decimal(0)) == 0 + broker.ledger.record_dividend_valuation(as_of=snapshot.event_time) + stored = export_dividend_run(broker.ledger, tmp_path / "hk-cost") + replayed = replay_dividend_run(stored).ledger + assert replayed.journal_sha256 == broker.ledger.journal_sha256 + assert replayed.snapshot() == snapshot + + +@pytest.mark.parametrize("side,sign", [(Side.BUY, 1), (Side.SELL, -1)]) +@pytest.mark.parametrize("close_quantity", ["1", "3", "4"]) +def test_derivative_partial_full_and_reversing_close_preserve_total_cost( + side, sign, close_quantity, tmp_path +): + ledger = account(derivative=True) + ledger.apply(fill("a", PERP, side, "1", "100", seconds=1)) + ledger.apply(fill("b", PERP, side, "2", "101", seconds=2)) + assert ledger.snapshot().nav.to_decimal() == 1000000 + ledger.mark(mark(PERP, "102", 3)) + assert ledger.snapshot().unrealized_pnl[PERP].to_decimal() == sign * 4 + closing_side = Side.SELL if side is Side.BUY else Side.BUY + snapshot = ledger.apply(fill("c", PERP, closing_side, close_quantity, "102", seconds=3)) + expected_cost = {"1": Decimal("201.33333333"), "3": Decimal(0), "4": Decimal(-102)} + assert book_cost(ledger, PERP) == sign * expected_cost[close_quantity] + assert snapshot.nav.to_decimal() == 1000000 + sign * 4 + expected_realized = Decimal("1.33333333") if close_quantity == "1" else Decimal(4) + assert snapshot.realized_pnl[PERP].to_decimal() == sign * expected_realized + assert ledger.risk_balances(snapshot.event_time)[2] == snapshot.nav.to_decimal() + assert ledger.portfolio_risk_snapshot(snapshot.event_time).nav == snapshot.nav + assert not ledger.liquidation_required() + ledger.record_dividend_valuation(as_of=snapshot.event_time) + replayed = replay_dividend_run(export_dividend_run(ledger, tmp_path / "derivative-cost")).ledger + assert replayed.snapshot() == snapshot + assert replayed.journal_sha256 == ledger.journal_sha256 + + +@pytest.mark.parametrize("side,sign", [(Side.BUY, 1), (Side.SELL, -1)]) +def test_derivative_daily_mark_uses_total_cost(side, sign): + ledger = account(derivative=True) + ledger.apply(fill("a", PERP, side, "1", "100", seconds=1)) + ledger.apply(fill("b", PERP, side, "2", "101", seconds=2)) + settled = ledger.apply( + Settlement( + settlement_id="settle", + account_id="account", + instrument_id=PERP, + amount=fp(sign * 4), + currency="USD", + event_time=T0 + timedelta(seconds=3), + settlement_type="daily_mark", + settlement_price=fp("102"), + ) + ) + assert settled.nav.to_decimal() == 1000000 + sign * 4 + assert settled.unrealized_pnl[PERP].units == 0 + assert book_cost(ledger, PERP) == sign * 306 + + +def test_reporting_nonterminating_cost_is_independent_of_decimal_context(): + ledger = account() + ledger.apply(fill("a", STOCK, Side.BUY, "1", "100", seconds=1)) + ledger.apply(fill("b", STOCK, Side.BUY, "2", "101", seconds=2)) + ledger.mark(mark(STOCK, "102", 3)) + expected = ledger.snapshot() + with localcontext() as context: + context.prec = 2 + context.traps[Inexact] = True + context.traps[Rounded] = True + assert ledger.snapshot() == expected + assert ledger.risk_balances(expected.event_time)[2] == Decimal(1000004) + assert ledger.portfolio_risk_snapshot(expected.event_time).nav == expected.nav + assert not ledger.liquidation_required() + + +@pytest.mark.parametrize("second_price,removed", [("100.01", "100.00"), ("100.03", "100.02")]) +def test_partial_allocation_half_even_keeps_residue_until_final_sale(second_price, removed): + ledger = account(money_scale=2) + ledger.apply(fill("a", STOCK, Side.BUY, "1", "100", seconds=1)) + ledger.apply(fill("b", STOCK, Side.BUY, "1", second_price, seconds=2)) + original_cost = book_cost(ledger, STOCK) + first = ledger.apply(fill("c", STOCK, Side.SELL, "1", "102", seconds=3)) + assert book_cost(ledger, STOCK) == original_cost - Decimal(removed) + assert first.realized_pnl[STOCK].to_decimal() == 102 - Decimal(removed) + last = ledger.apply(fill("d", STOCK, Side.SELL, "1", "102", seconds=4)) + assert book_cost(ledger, STOCK) == 0 + assert last.realized_pnl[STOCK].to_decimal() == 204 - original_cost + assert last.nav.to_decimal() == 1000000 + 204 - original_cost + + +def test_fractional_quantity_and_multiplier_are_allocated_from_total_cost(): + instrument_spec = replace( + stock_spec(), quantity_step=fp("0.001", 3), contract_multiplier=fp(10) + ) + ledger = ExactAccountLedger( + account_id="account", + base_currency="CNY", + instruments={STOCK: instrument_spec}, + initial_cash={"CNY": fp(1000000)}, + opened_at=T0, + dividend_execution_mode=DividendExecutionMode.SCENARIO_ONLY, + fx_valuation_mode=FxValuationMode.EVIDENCED_PIT, + ) + ledger.apply(fill("a", STOCK, Side.BUY, "0.100", "100", seconds=1)) + ledger.apply(fill("b", STOCK, Side.BUY, "0.200", "101", seconds=2)) + ledger.mark(mark(STOCK, "102", 3)) + first = ledger.apply(fill("c", STOCK, Side.SELL, "0.100", "102", seconds=3)) + assert first.realized_pnl[STOCK].to_decimal() == Decimal("1.33333333") + last = ledger.apply(fill("d", STOCK, Side.SELL, "0.200", "102", seconds=4)) + assert book_cost(ledger, STOCK) == 0 + assert last.realized_pnl[STOCK].to_decimal() == 4 + + +def test_generated_daily_settlement_after_partial_close_uses_remaining_cost(): + ledger = account(derivative=True) + ledger.apply(fill("a", PERP, Side.BUY, "1", "100", seconds=1)) + ledger.apply(fill("b", PERP, Side.BUY, "2", "101", seconds=2)) + ledger.mark(mark(PERP, "102", 3)) + ledger.apply(fill("c", PERP, Side.SELL, "1", "102", seconds=3)) + ledger.mark(mark(PERP, "102", 4)) + event = StatusEvent(**event_fields("settle-status", PERP, seconds=4), status="daily_settlement") + settlement = ledger.settlement_from_market(event) + assert settlement.amount.to_decimal() == Decimal("2.66666667") + snapshot = ledger.apply(settlement) + assert snapshot.unrealized_pnl[PERP].units == 0 + assert snapshot.nav.to_decimal() == 1000004 + + +def test_display_rounding_does_not_relax_exact_nav_precision_and_rollback(): + ledger = account(money_scale=2) + ledger.apply(fill("a", STOCK, Side.BUY, "1", "100", seconds=1)) + before = ledger.capture_state() + with pytest.raises(ValidationError, match="not exact at scale"): + ledger.mark(replace(mark(STOCK, "100", 2), price=fp("100.001", 3))) + assert ledger.capture_state() == before + + +def test_fraction_rounding_uses_integer_half_even_and_rejects_invalid_inputs(): + for numerator, expected in [(20001, 10000), (20003, 10002), (-20001, -10000), (-20003, -10002)]: + assert fixed_fraction_half_even(Fraction(numerator, 200), 2) == FixedPoint(expected, 2) + assert fixed_fraction_half_even(Fraction(1, 3), 8) == FixedPoint(33333333, 8) + assert fixed_fraction_half_even(Fraction(2, 3), 8) == FixedPoint(66666667, 8) + with pytest.raises(ValidationError, match="Fraction"): + fixed_fraction_half_even(Decimal(1), 2) + for scale in (-1, 19, True): + with pytest.raises(ValidationError): + fixed_fraction_half_even(Fraction(1), scale) + with pytest.raises(ValidationError): + fixed_fraction_half_even(Fraction(2**63), 0)