Skip to content

fix(execution): fill integration keys cash reasons and reservation release off OrderRole, not fill side (ALP-944) - #351

Merged
jatassi merged 2 commits into
mainfrom
jackson/alp-944-fill-integration-keys-role-semantics-off-fill-side-short
Jun 11, 2026
Merged

fix(execution): fill integration keys cash reasons and reservation release off OrderRole, not fill side (ALP-944)#351
jatassi merged 2 commits into
mainfrom
jackson/alp-944-fill-integration-keys-role-semantics-off-fill-side-short

Conversation

@jatassi

@jatassi jatassi commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fill integration treated buy=entry / sell=exit — correct for longs by coincidence, inverted for every leg of a short: short entry fills logged CASH_CREDITED with reason=EXIT_FILL, and the fill-time capital-reservation release only fired on buy-side fills, stranding short-entry stakes in cash_ledger.reserved_capital_usd forever ($4,561.38 across TXN + BAC in prod, distorting the PM risk budget).
  • Reason vocabularies widened: CashCreditReason gains ENTRY_FILL, CashDebitReason gains EXIT_FILL (JSON payload — no migration). event_type stays keyed off cash direction; reason now keys off the order's role (ENTRY/ADD_ENTRY vs the rest — the same partition the strategy dispatch and cancel path use).
  • The reserved_capital_usd decrement and CAPITAL_RELEASED emission are gated on entry-role instead of buy-side, mirroring the role-scoped staking sites. This also defuses the latent inverse: a short's protective buy legs would have released capital they never staked.
  • Tests: short entry sell fill (credits as ENTRY_FILL, releases its stake) and short cover buy fill (debits as EXIT_FILL, leaves the pool untouched); long-path and take-profit tests now assert the reason payload instead of event presence; cyclic seed boilerplate consolidated into one helper.

Follow-up candidate (out of scope per the issue): the ENTRY/ADD_ENTRY partition is still spelled independently in command_execution/cancel.py:139, risk_guardrails/library_snapshot.py:123, and command_center/alerts/conditions.py:702 — a shared constant next to PROTECTIVE_LEG_ROLE_VALUES in records/orders.py would converge them.

Closes https://linear.app/alphamind-jatassi/issue/ALP-944/fill-integration-keys-role-semantics-off-fill-side-short-cash-reasons

Test plan

  • CI (.github/workflows/ci.yml) green on the PR — lint on Linux + full pytest on Windows
  • Local lint chain clean (ruff check, ruff format --check, mypy, lint-imports)
  • Scoped suites green: tests/execution/state_persistence/ + tests/portfolio_state/ (1,515 tests)
  • Both new tests verified red against the pre-fix code (stash check)
  • ALP-944 acceptance criteria all met

🤖 Generated with Claude Code

jatassi and others added 2 commits June 10, 2026 18:08
…lease off OrderRole, not fill side (ALP-944)

Fill integration treated buy as entry and sell as exit — correct for longs
by coincidence, inverted for every leg of a short. Short entry fills (sells)
logged CASH_CREDITED reason=EXIT_FILL, and the fill-time capital-reservation
release only fired on buy-side fills, stranding short-entry stakes in
cash_ledger.reserved_capital_usd forever (and arming the inverse: a short's
protective buy legs would release capital they never staked).

- Widen the reason vocabularies: CashCreditReason gains ENTRY_FILL,
  CashDebitReason gains EXIT_FILL (detail_json payload — no migration).
- Key the cash reason off the order's role via the ENTRY/ADD_ENTRY
  partition the strategy dispatch and cancel path already use; event_type
  stays keyed off cash direction (every buy debits, every sell credits).
- Gate the reserved_capital_usd decrement and the CAPITAL_RELEASED
  emission on entry-role, mirroring the role-scoped staking sites.
- Tests: short entry sell fill credits as ENTRY_FILL and releases its
  stake; short cover buy fill debits as EXIT_FILL without touching the
  reservation pool; long-path tests now assert the reason payload instead
  of event presence; cyclic OPEN-cluster seeding deduped into one helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Use _is_entry_role at the strategy-dispatch site so the entry-role
  partition has one spelling in fill_collection.
- Fold the new OPEN-cluster seed helper into
  _seed_position_order_thesis_bracket via an extra_orders param; the
  take-profit-leg test adopts it too (fourth inline copy removed).
- Assert reason=EXIT_FILL on the take-profit leg fill's cash credit —
  the protective-role quadrant of the reason matrix was unasserted.
- Trim the _is_entry_role docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear

linear Bot commented Jun 11, 2026

Copy link
Copy Markdown
ALP-944 Fill integration keys role semantics off fill side — short cash reasons inverted, short entry reservations stranded

Summary

Corrected from the original report: the reported mechanism is confirmed, but "no cash impact / only the reason label is wrong" is refuted. The same side-for-role conflation also gates the fill-time capital-reservation release, so the two short entries stranded $4,561.38 in cash_ledger.reserved_capital_usd — which risk_budget.py:44 subtracts from the PM's deployable capital on every invocation right now. Also: the mislabeled-row count grew from 3 to 5 (a second short, BAC, opened 2026-06-10), and the report's suggested consumer audit comes back clean — no in-repo consumer reads the cash reason today (feedback-loop WindowDataset / digest tiers and command-center views never touch it).

Fill integration treats "buy" as "entry" and "sell" as "exit" in two places. Correct for longs by coincidence; inverted for every leg of a short:

  1. Short entry fills (sell) log CASH_CREDITED with reason=EXIT_FILL; the eventual buy-to-cover will log CASH_DEBITED with reason=ENTRY_FILL.
  2. Short entry orders stake a capital reservation at submission, but the fill-time release only fires on buy-side fills — the stake strands forever. Latent inverse: a short's protective legs are BUY orders with prices, so when one fills the code will release capital that was never staked, draining other orders' live reservations (floored at zero).

Cash arithmetic (current_cash_usd / settled_cash_usd) is correct and matches the broker.

Evidence (prod DB immutable snapshot, 2026-06-10 — POS-TXN / POS-BAC)

Aggregate over all genesis-account activity_log cash rows joined to positions:

event_type    / reason     / position direction / n
CASH_CREDITED / EXIT_FILL  / LONG               / 11   true exits — correct
CASH_CREDITED / EXIT_FILL  / SHORT              / 5    entry fills — mislabeled
CASH_DEBITED  / ENTRY_FILL / LONG               / 15   true entries — correct

The 5 mislabeled rows, all on FILLED sell-limit ENTRY-role orders:

TXN ×3  ORD-TXN-entry-0c48588c5ca7502eaf78cfef7bff7c1f  (SELL LIMIT 287.10, 9 sh)
        inv-20260610T170000Z-c71896f2, fills 14:04:19–20 UTC
        amounts 1442.30 + 865.38 + 288.46 = 2596.14
BAC ×2  ORD-BAC-entry-758df0ce1de15e1992180dd8be157ecf  (SELL LIMIT 54.93, 36 sh)
        inv-20260610T190000Z-f379259e, fills 17:31:26–27 UTC
        amounts 1319.04 + 659.52 = 1978.56

Reservation strand — the activity log and ledger reconcile exactly, and the residue is precisely the two short stakes:

Σ CAPITAL_RESERVED  = 23,057.62
Σ CAPITAL_RELEASED  = 17,001.52
difference          =  6,056.10  == cash_ledger.reserved_capital_usd

composition of the 6,056.10:
  1,494.72  ORD-DVN-entry  BUY LIMIT 46.71 × 32, PENDING   — legitimate live stake
  2,583.90  ORD-TXN-entry  SELL LIMIT 287.10 × 9, FILLED   — STRANDED (no release event)
  1,977.48  ORD-BAC-entry  SELL LIMIT 54.93 × 36, FILLED   — STRANDED (no release event)

Every long entry in account history (GS, BKR, TER, MRVL, TSM, SLB, COP) shows reserve-then-full-release; neither short entry has any CAPITAL_RELEASED. Consumers of the stranded field: portfolio_state/computations/risk_budget.py:44 (PM deployable capital) and risk_guardrails/library_snapshot.py:251 (reserved_for_pending_orders_usd guardrail input). Consumer sweep for the label: ENTRY_FILL / EXIT_FILL appear nowhere in src/ outside the emit site and the enum definitions.

Root cause (confirmed)

  1. src/alphamind/execution/write_paths/fill_collection.py:2226-2253 — the cash-flow activity emit branches reason on direction_is_buy (buy → CashDebitReason.ENTRY_FILL, sell → CashCreditReason.EXIT_FILL). The fill's order role is in scope (outcome.order.role) but unused.
  2. src/alphamind/portfolio_state/events/types.py:178-192 — the vocabularies themselves encode the conflation: CashCreditReason has no ENTRY_FILL member and CashDebitReason has no EXIT_FILL, so a short's legs are inexpressible without widening both enums.
  3. fill_collection.py:1976 (_apply_cash_movement) — the reserved_capital_usd decrement is gated on is_buy, while the staking sites (command_execution/open.py:330, add.py:147) are role-scoped with no side gate. A sell-side ENTRY fill therefore stakes and never releases; a buy-side protective-leg fill on a short (e.g. the live TXN take-profit, BUY LIMIT 273 × 9) would release 2,457 that was never staked.
  4. fill_collection.py:824 — the CAPITAL_RELEASED activity emission shares the same buy gate, so the audit event mirrors the same defect.
  5. The codebase already names the correct pattern: the CANCEL release is role-gated at command_execution/cancel.py:139 ("the role guard, not the floor, is what keeps protective cancels honest"), and the read-side per-position reservation view is role-gated at risk_guardrails/library_snapshot.py:123-151. Only the two fill-time gates predate shorts and were never generalized.

Why it escaped tests

The write-path integration tests assert cash-event presence only — tests/execution/state_persistence/test_fill_collection_write_path.py:708 and :818 check EventType.CASH_DEBITED.value in types / CASH_CREDITED in types without decoding detail_json, so no test has ever asserted a reason value. Every cash-event and reservation-lifecycle test drives a LONG position; the short-entry test (:1632) asserts the PENDING→OPEN transition and the four short-only fields but inspects neither the cash entry nor reserved_capital_usd. The first managed shorts reached production 2026-06-10 (TXN, then BAC), so the path was correct-by-coincidence for every position that existed before.

Reading

  • src/alphamind/execution/write_paths/fill_collection.py:2226-2253 — the defective reason branch; :2076 the enclosing _emit_fill_activity_log_entries; :1932-1984 _apply_cash_movement with the is_buy release gate at :1976; :824 the CAPITAL_RELEASED emission gate; :2001 _fill_reservation_release_usd (math is correct, unchanged); :1512 the canonical entry-role partition.
  • src/alphamind/portfolio_state/events/types.py:178-192 — the two reason enums to widen.
  • src/alphamind/portfolio_state/events/cash_margin.py:18-36 — the detail payloads carrying reason.
  • src/alphamind/portfolio_state/records/orders.py:25-31OrderRole; :270role is non-optional, so the role partition is total over every fill.
  • src/alphamind/execution/write_paths/command_execution/open.py:298-338 and add.py:145-147 — the role-scoped, side-blind staking sites the fill-time release must mirror.
  • src/alphamind/execution/write_paths/command_execution/cancel.py:132-160 — the already-correct role-gated release; its comment states the pattern this fix applies at the fill seam.
  • src/alphamind/risk_guardrails/library_snapshot.py:123-151 — read-side mirror, already role-gated; do not touch.
  • src/alphamind/portfolio_state/computations/risk_budget.py:44 — why the strand distorts the PM risk budget.
  • src/alphamind/state/tables/activity_log.py:32-126 — the table CHECKs cover event_type / event_group / source only; reason is JSON payload, so widening the enums needs no Alembic migration.
  • tests/execution/state_persistence/test_fill_collection_write_path.py:690-708, 800-819, 1632 — the presence-only assertions to tighten and the short-entry test to extend.

Scope — key role semantics off OrderRole at the fill seam (the cancel-path pattern)

(A) Widen the reason vocabularies. Add ENTRY_FILL to CashCreditReason and EXIT_FILL to CashDebitReason in portfolio_state/events/types.py. No migration and no CHECK change — reason lives inside activity_log.detail_json.

(B) Key the cash reason off the order's role. At fill_collection.py:2226-2253: event_type stays keyed off direction_is_buy (cash direction is genuinely side-shaped — every buy debits, every sell credits); reason becomes ENTRY_FILL when outcome.order.role is OrderRole.ENTRY or OrderRole.ADD_ENTRY, EXIT_FILL otherwise (TAKE_PROFIT / PRICE_STOP / TIME_STOP / CLOSE). This is the same partition fill_collection.py:1512 and cancel.py:139 already use.

(C) Gate the fill-time reservation release on role, not side. In _apply_cash_movement (fill_collection.py:1976), replace the is_buy gate on the reserved_capital_usd decrement with the ENTRY/ADD_ENTRY role gate; make the identical change to the CAPITAL_RELEASED emission gate at fill_collection.py:824 so the audit event mirrors the ledger movement. _fill_reservation_release_usd is unchanged (reservation-price basis is correct; market entries release 0).

(D) Tests. In tests/execution/state_persistence/test_fill_collection_write_path.py: a short entry sell fill emits CASH_CREDITED with reason=ENTRY_FILL, decrements reserved_capital_usd by reservation_price × fill_quantity, and emits CAPITAL_RELEASED; a buy fill on a CLOSE- or protective-role order against an OPEN SHORT emits CASH_DEBITED with reason=EXIT_FILL, leaves reserved_capital_usd unchanged, and emits no CAPITAL_RELEASED; tighten the existing long-path tests at :708 and :818 to decode detail_json and assert the reason payload instead of event presence only. Mock only at the sanctioned boundaries (DB).

Out of scope: the CANCEL-path release (cancel.py:139) and the read-side reservation view (library_snapshot.py:144) already implement the role partition and are not touched. Historical-row repair is the operator-executed cleanup below, not code.

Acceptance criteria

  • CashCreditReason carries ENTRY_FILL and CashDebitReason carries EXIT_FILL; no Alembic migration is added.
  • A sell fill integrating an ENTRY-role order on a SHORT position emits CASH_CREDITED with reason=ENTRY_FILL.
  • A buy fill integrating a CLOSE- or protective-role order on a SHORT position emits CASH_DEBITED with reason=EXIT_FILL.
  • Long-path reasons are unchanged and now asserted by payload: buy ENTRY fill → CASH_DEBITED / ENTRY_FILL; sell CLOSE fill → CASH_CREDITED / EXIT_FILL (tests decode detail_json, not presence-only).
  • A sell-side ENTRY fill decrements reserved_capital_usd by reservation_price × fill_quantity and emits CAPITAL_RELEASED.
  • A buy-side fill on a non-entry-role order leaves reserved_capital_usd unchanged and emits no CAPITAL_RELEASED.
  • event_type remains keyed off cash direction: every buy fill emits CASH_DEBITED and every sell fill CASH_CREDITED, regardless of role.

Verification

Scoped runs while implementing: uv run pytest tests/execution/state_persistence/ -n auto (owning dir) and uv run pytest tests/portfolio_state/events/ -n auto (enum/codec/registry fallout). Lint chain: uv run ruff check . && uv run ruff format . && uv run mypy && uv run lint-imports. The first acceptance criterion is verified by inspection of types.py; all others by the named tests. The authoritative full-suite gate is CI on Windows.

One-time prod data cleanup (manual, Windows prod box; source .env)

Run after the fix deploys — until then the running code keeps minting mislabeled rows and stranding new short stakes, and a backfilled ENTRY_FILL credit row only decodes under the widened enum. Writes must run on the prod box (the SMB mount is read-only); run with the scheduler and monitor quiescent per scripts/RUNBOOK_production.md.

Step 1 — relabel the mislabeled rows (role-driven, idempotent). As of the 2026-06-10 snapshot this hits exactly 5 rows (TXN ×3, BAC ×2); the predicate also catches any further short legs that land before deploy:

UPDATE activity_log
SET detail_json = json_set(detail_json, '$.reason', 'ENTRY_FILL')
WHERE event_type = 'CASH_CREDITED'
  AND json_extract(detail_json, '$.reason') = 'EXIT_FILL'
  AND order_id IN (SELECT order_id FROM orders
                   WHERE order_role IN ('ENTRY','ADD_ENTRY'));

UPDATE activity_log
SET detail_json = json_set(detail_json, '$.reason', 'EXIT_FILL')
WHERE event_type = 'CASH_DEBITED'
  AND json_extract(detail_json, '$.reason') = 'ENTRY_FILL'
  AND order_id IN (SELECT order_id FROM orders
                   WHERE order_role IN ('TAKE_PROFIT','PRICE_STOP','TIME_STOP','CLOSE'));

Step 2 — recompute reserved_capital_usd from the staking invariant (sum of live entry-order reserved notionals — do not hardcode 1,494.72; pre-deploy fills can change the composition). Derive the target:

SELECT COALESCE(SUM(
  CAST(COALESCE(json_extract(price_parameters_json,'$.limit_price'),
                json_extract(price_parameters_json,'$.stop_trigger_price')) AS REAL)
  * remaining_quantity), 0)
FROM orders
WHERE order_role IN ('ENTRY','ADD_ENTRY')
  AND status IN ('PENDING_SUBMIT','PENDING','PARTIALLY_FILLED')
  AND COALESCE(json_extract(price_parameters_json,'$.limit_price'),
               json_extract(price_parameters_json,'$.stop_trigger_price')) IS NOT NULL;

Then write the result as a literal in the column's DecimalText format (three decimals, e.g. '1494.720'):

UPDATE cash_ledger SET reserved_capital_usd = '<derived value>' WHERE id = 'current';

Cash hazard: current_cash_usd and settled_cash_usd are correct and already match the broker — touch only reserved_capital_usd. No synthetic CAPITAL_RELEASED rows are inserted for the stranded stakes; the activity log remains the honest record of what the defective code did, and this issue documents the gap.

Step 3 — verify. The role-consistency sweep returns 0; reserved_capital_usd equals the Step-2 derivation; current_cash_usd is unchanged:

SELECT COUNT(*) FROM activity_log a JOIN orders o ON o.order_id = a.order_id
WHERE (a.event_type='CASH_CREDITED'
       AND json_extract(a.detail_json,'$.reason')='EXIT_FILL'
       AND o.order_role IN ('ENTRY','ADD_ENTRY'))
   OR (a.event_type='CASH_DEBITED'
       AND json_extract(a.detail_json,'$.reason')='ENTRY_FILL'
       AND o.order_role IN ('TAKE_PROFIT','PRICE_STOP','TIME_STOP','CLOSE'));

Related

  • ALP-741 — established the reservation-price × quantity basis and lifecycle this fix completes for sell-side entries.
  • ALP-760 — added the cancel-side role guard (cancel.py:139) whose comment names the exact pattern the fill seam now adopts.
  • The CANCELLED BAC protective-leg rows visible in the evidence are the ALP-937 / ALP-938 partial-CLOSE flow, unrelated to this defect.

Review in Linear

@jatassi
jatassi merged commit 7077418 into main Jun 11, 2026
0 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant