diff --git a/src/alphamind/execution/write_paths/fill_collection.py b/src/alphamind/execution/write_paths/fill_collection.py index 7032ba23..508150b2 100644 --- a/src/alphamind/execution/write_paths/fill_collection.py +++ b/src/alphamind/execution/write_paths/fill_collection.py @@ -821,7 +821,7 @@ async def _integrate_one_fill( ) cash_delta = await _apply_cash_movement(handle, order, fill) - if direction_is_buy: + if _is_entry_role(order): await _emit_capital_release(handle, order, fill) outcome = _FillIntegrationOutcome( @@ -1509,7 +1509,7 @@ async def _apply_strategy_open_fill( parent's TAKE_PROFIT / PRICE_STOP / TIME_STOP role — those route to the close branch correctly (ALP-614). """ - is_opening_for_leg = updated_order.role in {OrderRole.ENTRY, OrderRole.ADD_ENTRY} + is_opening_for_leg = _is_entry_role(updated_order) if is_opening_for_leg: new_legs = _add_to_leg(details.legs, leg=leg, fill=fill) new_details = _recompute_strategy_payoff_metrics( @@ -1938,7 +1938,9 @@ async def _apply_cash_movement( ``OptionsInstrumentSpec`` (typically 100). Equity consideration is ``fill_price * fill_quantity`` directly. - Buy-side fills additionally release the capital command execution reserved for this + Entry-role fills (ENTRY / ADD_ENTRY — the roles whose submission staked a + reservation, regardless of side; a short's entry is a sell, ALP-944) + additionally release the capital command execution reserved for this entry order — by the order's *reserved notional* attributable to the filled quantity (``_fill_reservation_release_usd``), NOT the fill consideration. Reserve and release share the ``reservation_price * quantity`` basis (ALP-741) @@ -1973,7 +1975,7 @@ async def _apply_cash_movement( cash_row.current_cash_usd = cash_row.current_cash_usd + delta # ALP-778: settled tracks current (no T+2 lag modelled in paper trading). cash_row.settled_cash_usd = cash_row.current_cash_usd - if is_buy: + if _is_entry_role(order): release = _fill_reservation_release_usd(order, fill) # Floor at zero (defensive) and wrap in money() — the same non-negative # invariant _release_capital enforces on the command execution side (ALP-741). @@ -1998,8 +2000,15 @@ def _fill_consideration_usd(order: OrderRecord, fill: FillRecord) -> Decimal: return base +def _is_entry_role(order: OrderRecord) -> bool: + """Entry-role partition: only ENTRY / ADD_ENTRY stake capital at submission, + so only their fills release it — keyed off role, never off fill side + (a short's entry is a sell, its protective legs are buys, ALP-944).""" + return order.role in (OrderRole.ENTRY, OrderRole.ADD_ENTRY) + + def _fill_reservation_release_usd(order: OrderRecord, fill: FillRecord) -> Decimal: - """Reserved capital released by a buy-side fill (ALP-741). + """Reserved capital released by an entry-role fill (ALP-741). The order's reservation price (``limit_price`` preferred, ``stop_trigger_price`` fallback) times *this fill's* quantity — the same ``reservation_price * @@ -2050,7 +2059,7 @@ async def _read_cash_row_or_raise(handle: InvocationHandle) -> CashLedgerRow: async def _emit_capital_release( handle: InvocationHandle, order: OrderRecord, fill: FillRecord ) -> None: - """Buy-side fills release the capital reservation command execution staked. + """Entry-role fills release the capital reservation command execution staked. The emitted amount is the order's reserved notional for the filled quantity (``_fill_reservation_release_usd``) — the same basis @@ -2223,6 +2232,10 @@ async def _emit_fill_activity_log_entries( ) new_balance = await _current_cash_balance(handle) + # event_type keys off cash direction (every buy debits, every sell + # credits); reason keys off the order's role — a short's entry is a sell + # that credits cash with reason=ENTRY_FILL (ALP-944). + is_entry_fill = _is_entry_role(order) if direction_is_buy: _emit( handle, @@ -2233,7 +2246,7 @@ async def _emit_fill_activity_log_entries( timestamp=fill.fill_timestamp, detail=CashDebitedDetail( amount_usd=money(abs(cash_delta_usd)), - reason=CashDebitReason.ENTRY_FILL, + reason=(CashDebitReason.ENTRY_FILL if is_entry_fill else CashDebitReason.EXIT_FILL), new_balance_usd=signed_money(new_balance), ), ) @@ -2247,7 +2260,9 @@ async def _emit_fill_activity_log_entries( timestamp=fill.fill_timestamp, detail=CashCreditedDetail( amount_usd=money(abs(cash_delta_usd)), - reason=CashCreditReason.EXIT_FILL, + reason=( + CashCreditReason.ENTRY_FILL if is_entry_fill else CashCreditReason.EXIT_FILL + ), new_balance_usd=signed_money(new_balance), ), ) diff --git a/src/alphamind/portfolio_state/events/types.py b/src/alphamind/portfolio_state/events/types.py index 2089ab42..d386cd88 100644 --- a/src/alphamind/portfolio_state/events/types.py +++ b/src/alphamind/portfolio_state/events/types.py @@ -176,16 +176,29 @@ class OrderRejectionSource(StrEnum): class CashDebitReason(StrEnum): - """Reason for a cash debit event.""" + """Reason for a cash debit event. + + Both fill reasons appear in both vocabularies (ALP-944): cash direction is + side-shaped (every buy debits, every sell credits) while entry/exit is + role-shaped, so a short's buy-to-cover is a debit with ``EXIT_FILL``. + Stored inside ``activity_log.detail_json`` (not a CHECK-constrained + column), so no migration is required for these members. + """ ENTRY_FILL = "ENTRY_FILL" + EXIT_FILL = "EXIT_FILL" FEES = "FEES" CASH_DIVIDEND_SHORT_OBLIGATION = "CASH_DIVIDEND_SHORT_OBLIGATION" class CashCreditReason(StrEnum): - """Reason for a cash credit event.""" + """Reason for a cash credit event. + Carries ``ENTRY_FILL`` for the same reason ``CashDebitReason`` carries + ``EXIT_FILL`` — a short's entry is a sell that credits cash (ALP-944). + """ + + ENTRY_FILL = "ENTRY_FILL" EXIT_FILL = "EXIT_FILL" CASH_DIVIDEND_LONG = "CASH_DIVIDEND_LONG" FRACTIONAL_SHARE_CASH_OUT = "FRACTIONAL_SHARE_CASH_OUT" diff --git a/tests/execution/state_persistence/test_fill_collection_write_path.py b/tests/execution/state_persistence/test_fill_collection_write_path.py index e7856d39..15911387 100644 --- a/tests/execution/state_persistence/test_fill_collection_write_path.py +++ b/tests/execution/state_persistence/test_fill_collection_write_path.py @@ -38,9 +38,15 @@ ) from alphamind.persistence.session import begin_write_immediate from alphamind.portfolio_state.events.activity_log import ( + CapitalReleasedDetail, + CashCreditedDetail, + CashCreditReason, + CashDebitedDetail, + CashDebitReason, CorporateActionType, EventType, ) +from alphamind.portfolio_state.events.codec import decode_detail from alphamind.portfolio_state.records.cash import CashLedger from alphamind.portfolio_state.records.orders import ( BracketLeg, @@ -493,6 +499,8 @@ async def _seed_position_order_thesis_bracket( order: OrderRecord, thesis: ThesisRecord, bracket: BracketRecord, + *, + extra_orders: tuple[OrderRecord, ...] = (), ) -> None: """Seed a full position cluster in a single deferred-FK transaction. @@ -502,6 +510,8 @@ async def _seed_position_order_thesis_bracket( Protective-leg order_ids (deferred FK to orders) are also seeded as stub orders in the same transaction so the COMMIT does not raise IntegrityError. + ``extra_orders`` seeds further real orders alongside (e.g. a pending + CLOSE-role order against an OPEN position). """ from tests.state._fk_substrate import stub_order_row @@ -509,8 +519,8 @@ async def _seed_position_order_thesis_bracket( bracket_row, leg_rows = bracket_record_to_rows(bracket) # Collect every order_id that appears in the bracket or its legs but is not - # the main order being seeded: brackets.entry_order_id and each leg.order_id. - seeded_order_ids: set[str] = {order.order_id} + # an order being seeded: brackets.entry_order_id and each leg.order_id. + seeded_order_ids: set[str] = {order.order_id, *(o.order_id for o in extra_orders)} extra_order_ids: list[str] = [bracket_row.entry_order_id] + [ lrow.order_id for lrow in leg_rows if lrow.order_id is not None ] @@ -521,6 +531,8 @@ async def _seed_position_order_thesis_bracket( for crow in component_rows: sess.add(crow) sess.add(order_record_to_row(order)) + for extra in extra_orders: + sess.add(order_record_to_row(extra)) for oid in extra_order_ids: if oid not in seeded_order_ids: sess.add(stub_order_row(oid, bracket.bracket_id)) @@ -705,7 +717,13 @@ async def test_entry_fill_transitions_pending_position_to_open( assert EventType.POSITION_OPENED.value in types assert EventType.BRACKET_ACTIVATED.value in types assert EventType.CAPITAL_RELEASED.value in types - assert EventType.CASH_DEBITED.value in types + # The buy entry's debit carries reason=ENTRY_FILL — keyed off the order's + # role, asserted by payload rather than event presence (ALP-944). + debited = [r for r in log_rows if r.event_type == EventType.CASH_DEBITED.value] + assert len(debited) == 1 + debit_detail = decode_detail(debited[0].detail_json, CashDebitedDetail) + assert debit_detail.reason == CashDebitReason.ENTRY_FILL + assert debit_detail.amount_usd == money(1_500.0) async def test_exit_fill_closes_position_and_leaves_thesis_active( @@ -720,38 +738,21 @@ async def test_exit_fill_closes_position_and_leaves_thesis_active( _, factory = db await _seed_invocation_substrate(factory) - close_order = _make_pending_entry_order( - order_id=OrderId("ord-close-1"), - role=OrderRole.CLOSE, - direction=OrderDirection.SELL, - position_id=PositionId("pos-1"), + await _seed_position_order_thesis_bracket( + factory, + _make_open_position(), + _make_pending_entry_order(), + _make_active_thesis(), + _make_active_bracket(), + extra_orders=( + _make_pending_entry_order( + order_id=OrderId("ord-close-1"), + role=OrderRole.CLOSE, + direction=OrderDirection.SELL, + position_id=PositionId("pos-1"), + ), + ), ) - # All four entities reference each other cyclically — seed in one transaction. - # _make_active_bracket uses entry_order_id=OrderId("ord-entry-1") and a protective leg - # with order_id=OrderId("brk-1-ord-stop"), so we need stubs for all referenced orders. - from tests.state._fk_substrate import stub_order_row - - entry_order = _make_pending_entry_order() - thesis_row, component_rows = thesis_record_to_rows(_make_active_thesis()) - bracket_row, leg_rows = bracket_record_to_rows(_make_active_bracket()) - leg_order_ids = [lrow.order_id for lrow in leg_rows if lrow.order_id is not None] - seeded_order_ids: set[str] = {entry_order.order_id, close_order.order_id} - async with factory() as sess: - sess.add(position_record_to_row(_make_open_position())) - sess.add(thesis_row) - for crow in component_rows: - sess.add(crow) - sess.add(order_record_to_row(entry_order)) - sess.add(order_record_to_row(close_order)) - for oid in leg_order_ids: - if oid not in seeded_order_ids: - sess.add(stub_order_row(oid, bracket_row.bracket_id)) - seeded_order_ids.add(oid) - sess.add(bracket_row) - await sess.flush() - for lrow in leg_rows: - sess.add(lrow) - await sess.commit() await _seed_cash_ledger(factory, _make_cash_ledger(current_cash_usd=98_500.0)) await _seed_drawdown_state(factory) # Sell 10 shares at $160 -> realized P/L = (160 - 150) * 10 = $100. @@ -815,8 +816,14 @@ async def test_exit_fill_closes_position_and_leaves_thesis_active( assert EventType.POSITION_CLOSED.value in types assert EventType.BRACKET_DISSOLVED.value in types assert EventType.THESIS_RESOLVED.value not in types - assert EventType.CASH_CREDITED.value in types assert EventType.ORDER_FILLED.value in types + # The sell close's credit carries reason=EXIT_FILL — keyed off the order's + # role, asserted by payload rather than event presence (ALP-944). + credited = [r for r in log_rows if r.event_type == EventType.CASH_CREDITED.value] + assert len(credited) == 1 + credit_detail = decode_detail(credited[0].detail_json, CashCreditedDetail) + assert credit_detail.reason == CashCreditReason.EXIT_FILL + assert credit_detail.amount_usd == money(1_600.0) def _make_bracket_with_leg_status( @@ -861,36 +868,23 @@ async def _run_partial_close_and_read_position( """Seed an OPEN position + ACTIVE bracket (legs at *leg_status*) + CLOSE order, process a partial-close fill (sell 4 of 10), and return the re-read position.""" from alphamind.execution.write_paths.fill_collection import process_unprocessed_fills - from tests.state._fk_substrate import stub_order_row await _seed_invocation_substrate(factory) - close_order = _make_pending_entry_order( - order_id=OrderId("ord-close-1"), - role=OrderRole.CLOSE, - direction=OrderDirection.SELL, - position_id=PositionId("pos-1"), + await _seed_position_order_thesis_bracket( + factory, + _make_open_position(share_count=10.0), + _make_pending_entry_order(), + _make_active_thesis(), + _make_bracket_with_leg_status(leg_status), + extra_orders=( + _make_pending_entry_order( + order_id=OrderId("ord-close-1"), + role=OrderRole.CLOSE, + direction=OrderDirection.SELL, + position_id=PositionId("pos-1"), + ), + ), ) - entry_order = _make_pending_entry_order() - thesis_row, component_rows = thesis_record_to_rows(_make_active_thesis()) - bracket_row, leg_rows = bracket_record_to_rows(_make_bracket_with_leg_status(leg_status)) - leg_order_ids = [lrow.order_id for lrow in leg_rows if lrow.order_id is not None] - seeded_order_ids: set[str] = {entry_order.order_id, close_order.order_id} - async with factory() as sess: - sess.add(position_record_to_row(_make_open_position(share_count=10.0))) - sess.add(thesis_row) - for crow in component_rows: - sess.add(crow) - sess.add(order_record_to_row(entry_order)) - sess.add(order_record_to_row(close_order)) - for oid in leg_order_ids: - if oid not in seeded_order_ids: - sess.add(stub_order_row(oid, bracket_row.bracket_id)) - seeded_order_ids.add(oid) - sess.add(bracket_row) - await sess.flush() - for lrow in leg_rows: - sess.add(lrow) - await sess.commit() await _seed_cash_ledger(factory, _make_cash_ledger(current_cash_usd=98_500.0)) await _seed_drawdown_state(factory) # Sell 4 of 10 shares -> position stays OPEN with 6 remaining (partial close). @@ -954,37 +948,24 @@ async def test_take_profit_leg_fill_marks_leg_filled_and_closes_position( from alphamind.execution.write_paths.fill_collection import ( process_unprocessed_fills, ) - from tests.state._fk_substrate import stub_order_row _, factory = db await _seed_invocation_substrate(factory) - tp_order = _make_pending_entry_order( - order_id=OrderId("ord-tp-1"), - role=OrderRole.TAKE_PROFIT, - direction=OrderDirection.SELL, - position_id=PositionId("pos-1"), + await _seed_position_order_thesis_bracket( + factory, + _make_open_position(), + _make_pending_entry_order(), + _make_active_thesis(), + _make_active_bracket(), + extra_orders=( + _make_pending_entry_order( + order_id=OrderId("ord-tp-1"), + role=OrderRole.TAKE_PROFIT, + direction=OrderDirection.SELL, + position_id=PositionId("pos-1"), + ), + ), ) - entry_order = _make_pending_entry_order() - thesis_row, component_rows = thesis_record_to_rows(_make_active_thesis()) - bracket_row, leg_rows = bracket_record_to_rows(_make_active_bracket()) - leg_order_ids = [lrow.order_id for lrow in leg_rows if lrow.order_id is not None] - seeded_order_ids: set[str] = {entry_order.order_id, tp_order.order_id} - async with factory() as sess: - sess.add(position_record_to_row(_make_open_position())) - sess.add(thesis_row) - for crow in component_rows: - sess.add(crow) - sess.add(order_record_to_row(entry_order)) - sess.add(order_record_to_row(tp_order)) - for oid in leg_order_ids: - if oid not in seeded_order_ids: - sess.add(stub_order_row(oid, bracket_row.bracket_id)) - seeded_order_ids.add(oid) - sess.add(bracket_row) - await sess.flush() - for lrow in leg_rows: - sess.add(lrow) - await sess.commit() await _seed_cash_ledger(factory, _make_cash_ledger(current_cash_usd=98_500.0)) await _seed_drawdown_state(factory) await _append_fill( @@ -1014,6 +995,25 @@ async def test_take_profit_leg_fill_marks_leg_filled_and_closes_position( ).scalar_one() assert position_row_to_record(pos_row).status == PositionStatus.CLOSED + # A protective-role sell is an exit even though the role partition is + # spelled positively (ENTRY/ADD_ENTRY) — guards the TAKE_PROFIT quadrant + # of the reason matrix (ALP-944). + log_rows = ( + ( + await sess.execute( + select(ActivityLogRow).where( + ActivityLogRow.invocation_id == handle.invocation_id + ) + ) + ) + .scalars() + .all() + ) + credited = [r for r in log_rows if r.event_type == EventType.CASH_CREDITED.value] + assert len(credited) == 1 + credit_detail = decode_detail(credited[0].detail_json, CashCreditedDetail) + assert credit_detail.reason == CashCreditReason.EXIT_FILL + async def test_multi_fill_ordering_produces_cumulative_state( db: tuple[AsyncEngine, async_sessionmaker[AsyncSession]], @@ -1693,6 +1693,171 @@ async def test_short_entry_fill_transitions_pending_short_to_open_with_stamped_f assert pos.details.margin_held_usd == pytest.approx(10.0 * 150.0 * 0.50) +async def test_short_entry_sell_fill_credits_cash_as_entry_and_releases_reservation( + db: tuple[AsyncEngine, async_sessionmaker[AsyncSession]], +) -> None: + """A sell fill on a SHORT ENTRY order is an *entry*: the cash credit + carries ``reason=ENTRY_FILL`` and the capital OPEN staked for the limit + entry is released on the same ``reservation_price * fill_quantity`` basis + as buy-side entries (ALP-944 — the side-keyed code labeled the credit + EXIT_FILL and stranded the stake forever).""" + from alphamind.execution.write_paths.fill_collection import ( + process_unprocessed_fills, + ) + + _, factory = db + await _seed_invocation_substrate(factory) + await _seed_position_order_thesis_bracket( + factory, + _make_pending_position(direction=Direction.SHORT), + # Sell-limit entry @ $150 x 10 — OPEN reserved 1500 at submission. + _make_pending_entry_order( + order_id=OrderId("ord-short-entry"), + direction=OrderDirection.SELL_TO_OPEN, + position_id=PositionId("pos-1"), + limit_price=150.0, + ), + _make_active_thesis(), + _make_pending_bracket(), + ) + await _seed_cash_ledger( + factory, + _make_cash_ledger(current_cash_usd=100_000.0, reserved_capital_usd=1_500.0), + ) + await _seed_drawdown_state(factory) + await _append_fill( + factory, + _make_unprocessed_fill(fill_id="fill-short-1", order_id=OrderId("ord-short-entry")), + ) + + ctx, handle = await _open_handle(factory) + await process_unprocessed_fills( + handle, + market_inputs=_make_market_inputs(), + config=_make_state_persistence_config(), + borrow_cost_resolver=lambda _ticker: 15.0, + ) + await ctx.__aexit__(None, None, None) + + async with factory() as sess: + # Short proceeds credit cash; the staked reservation drains to zero. + cash_row = await sess.get(CashLedgerRow, CASH_LEDGER_SINGLETON_ID) + assert cash_row is not None + assert cash_row.current_cash_usd == pytest.approx(100_000.0 + 1_500.0) + assert cash_row.reserved_capital_usd == money(0.0) + + log_rows = ( + ( + await sess.execute( + select(ActivityLogRow).where( + ActivityLogRow.invocation_id == handle.invocation_id + ) + ) + ) + .scalars() + .all() + ) + credited = [r for r in log_rows if r.event_type == EventType.CASH_CREDITED.value] + assert len(credited) == 1 + credit_detail = decode_detail(credited[0].detail_json, CashCreditedDetail) + assert credit_detail.reason == CashCreditReason.ENTRY_FILL + assert credit_detail.amount_usd == money(1_500.0) + released = [r for r in log_rows if r.event_type == EventType.CAPITAL_RELEASED.value] + assert len(released) == 1 + release_detail = decode_detail(released[0].detail_json, CapitalReleasedDetail) + assert release_detail.amount_usd == money(1_500.0) + + +async def test_short_cover_buy_fill_debits_cash_as_exit_without_touching_reservation( + db: tuple[AsyncEngine, async_sessionmaker[AsyncSession]], +) -> None: + """A buy fill on a CLOSE-role order against an OPEN SHORT is an *exit*: the + cash debit carries ``reason=EXIT_FILL`` and the reservation pool is left + alone — a non-entry order never staked capital, so its fill must not + release any (ALP-944 — the side-keyed gate would have drained another + order's live stake by this cover's ``limit_price * quantity``).""" + from alphamind.execution.write_paths.fill_collection import ( + process_unprocessed_fills, + ) + + _, factory = db + await _seed_invocation_substrate(factory) + await _seed_position_order_thesis_bracket( + factory, + _make_open_position(direction=Direction.SHORT), + _make_pending_entry_order(direction=OrderDirection.SELL_TO_OPEN), + _make_active_thesis(), + _make_active_bracket(), + extra_orders=( + _make_pending_entry_order( + order_id=OrderId("ord-cover-1"), + role=OrderRole.CLOSE, + direction=OrderDirection.BUY_TO_CLOSE, + position_id=PositionId("pos-1"), + # A priced cover is the trap: the side-keyed release would have + # drained 140 * 10 = 1400 of capital this order never staked. + limit_price=140.0, + ), + ), + ) + # 2000 of reserved capital belongs to *other* live entry orders. + await _seed_cash_ledger( + factory, + _make_cash_ledger(current_cash_usd=100_000.0, reserved_capital_usd=2_000.0), + ) + await _seed_drawdown_state(factory) + # Cover 10 shares at $140 against a $150 short basis -> realized P/L +100. + await _append_fill( + factory, + _make_unprocessed_fill( + fill_id="fill-cover-1", + order_id=OrderId("ord-cover-1"), + fill_price=140.0, + fill_quantity=10.0, + ), + ) + + ctx, handle = await _open_handle(factory) + await process_unprocessed_fills( + handle, + market_inputs=_make_market_inputs(), + config=_make_state_persistence_config(), + borrow_cost_resolver=lambda _ticker: 15.0, + ) + await ctx.__aexit__(None, None, None) + + async with factory() as sess: + pos_row = ( + await sess.execute(select(PositionRow).where(PositionRow.position_id == "pos-1")) + ).scalar_one() + pos = position_row_to_record(pos_row) + assert pos.status == PositionStatus.CLOSED + assert pos.realized_pnl_to_date_usd == pytest.approx(100.0) + + cash_row = await sess.get(CashLedgerRow, CASH_LEDGER_SINGLETON_ID) + assert cash_row is not None + assert cash_row.current_cash_usd == pytest.approx(100_000.0 - 1_400.0) + assert cash_row.reserved_capital_usd == money(2_000.0) + + log_rows = ( + ( + await sess.execute( + select(ActivityLogRow).where( + ActivityLogRow.invocation_id == handle.invocation_id + ) + ) + ) + .scalars() + .all() + ) + debited = [r for r in log_rows if r.event_type == EventType.CASH_DEBITED.value] + assert len(debited) == 1 + debit_detail = decode_detail(debited[0].detail_json, CashDebitedDetail) + assert debit_detail.reason == CashDebitReason.EXIT_FILL + assert debit_detail.amount_usd == money(1_400.0) + assert not [r for r in log_rows if r.event_type == EventType.CAPITAL_RELEASED.value] + + # --------------------------------------------------------------------------- # Unit-level coverage of the equity dispatcher's 8-case routing matrix # (status x direction x buy_side) + entry-fill resolver contracts. These diff --git a/tests/portfolio_state/records/test_activity_log.py b/tests/portfolio_state/records/test_activity_log.py index 7622e6ec..17d9f31f 100644 --- a/tests/portfolio_state/records/test_activity_log.py +++ b/tests/portfolio_state/records/test_activity_log.py @@ -257,14 +257,18 @@ def test_order_rejection_source_members(self) -> None: assert {m.name for m in OrderRejectionSource} == {"GUARDRAIL", "BROKER"} def test_cash_debit_reason_members(self) -> None: + # Both fill reasons live in both vocabularies (ALP-944): event_type is + # side-shaped, reason is role-shaped — a short's cover debits as EXIT_FILL. assert {m.name for m in CashDebitReason} == { "ENTRY_FILL", + "EXIT_FILL", "FEES", "CASH_DIVIDEND_SHORT_OBLIGATION", } def test_cash_credit_reason_members(self) -> None: assert {m.name for m in CashCreditReason} == { + "ENTRY_FILL", "EXIT_FILL", "CASH_DIVIDEND_LONG", "FRACTIONAL_SHARE_CASH_OUT",