feat: native exit bracket, order reconciliation, and a per-tranche position ledger - #96
Merged
Merged
Conversation
…state Both cancel sites did `cancel = getattr(broker, "cancel_order", None)` and skipped silently when it was absent -- and the real broker has no `cancel_order` at all. `cb_client`, the Coinbase adapter and the `Transport` protocol all lack it; only the three test fakes define one. So in production the cancel was always skipped while the order row was still marked `canceled`, leaving a live resting SELL on the exchange that our own records said was gone. After a stop filled, the target leg could still sell inventory we no longer held. `roll_to_break_even` is the worse of the two: it places the replacement stop BEFORE cancelling the old one, so a skipped cancel leaves two live stops on a single position and whichever fires second sells inventory the first already sold. The tests passed precisely because the fakes supply a method the real client lacks -- the same reads-as-enforced-but-isn't pattern this branch exists to kill, sitting on the cancel path. Both sites now route through `_cancel_at_exchange`, which raises `CancelUnavailable` on every failure mode (no method on the broker, no broker-side id to name, or the call itself raising) and never marks local state. The exchange is the source of truth, so the cancel must succeed there before anything records it; our state must never claim a cancel that did not happen. No blast radius today: neither `handle_oco_fill` nor `roll_to_break_even` has a production caller. This lands before that wiring, not after. Mutation-checked: restoring the silent skip fails exactly the three new tests. 946 passing, ruff and mypy clean, backtest baseline byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`place_oco_bracket` placed two independent SELL legs and paired them client-side in `agent_state`, so closing a position correctly depended on US observing a fill and cancelling the survivor. A missed fill left a live order able to sell an already-closed position. It also sized BOTH legs at the full qty, committing a 1x position 2x -- on spot the second leg should simply have been rejected for insufficient funds. `place_bracket` places ONE native `trigger_bracket_gtc` order carrying the take-profit as `limit_price` and the stop as `stop_trigger_price`. The exchange owns the race between them, so the sibling-cancellation failure mode does not exist rather than being handled carefully. `handle_oco_fill` and the `oco_sibling:` state are deleted. No new broker API surface was needed: the SDK's `trigger_bracket_order_gtc` is a thin wrapper over `create_order` with that order_configuration, so it reaches the exchange through the `place_order` path already in use. One accepted regression. `_roll_stop` must now cancel before placing, because the resting bracket commits the whole position and a replacement would be rejected for insufficient funds. That opens a brief window with no protective stop, which the old place-then-cancel ordering did not have. `edit_order` cannot avoid it: limit-GTC only, and it edits size/price, never `stop_trigger_price`. A rejected replacement now logs CRITICAL `executor.position_unprotected` and returns None; any production caller of `roll_to_break_even`/`trail_stop_atr` must handle it. Three tests were deleted deliberately, each with a note in place: the two `handle_oco_fill` sibling tests, whose invariant is now the exchange's, and the two-live-stops test, whose hazard is unreachable under cancel-first. Five added. Mutation-checked: dropping the target price, silencing the CRITICAL, and reverting to place-then-cancel each fail a test. 948 passing, ruff and mypy clean, backtest baseline byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nothing ever re-read order status, so a bracket that filled at the exchange was invisible. A stop-out -- the dominant source of losses -- closed a position the agent never noticed: the row stayed `pending`, `_held_position` kept counting sold inventory as held, `position_rule` was never cleared, and no `trade_outcomes` row was written. Rail 16 therefore counted only voluntary rule exits, systematically under-counting exactly the losing side it exists to react to, and rail 11's equity was computed against positions that no longer existed. `reconcile_open_orders` runs at the top of every cycle, before equity and before any entry, since a fill that already happened has changed both the position and the cash balance. It also upgrades two numbers from modelled to observed, retiring compromises made earlier on this branch: `actual_fill` was the EXPECTED price and is now `average_filled_price`; `fee` was the PREVIEWED commission and is now `total_fees`. Realized P&L is now what the exchange actually charged. Adds `get_order` and `cancel_order` to `CoinbaseClient` and its `Transport`. `cancel_order` matters beyond reconciliation: the real client had no such method at all, so `_cancel_at_exchange` would have raised `CancelUnavailable` on every production cancel. Coinbase's batch_cancel reports success per order, so a 200 does not mean the order is gone -- it returns True only on a confirmed per-order success and treats an empty result set as failure, because absence of a refusal is not a confirmation. Deliberate semantics: a partial fill is left resting rather than recorded, as it has not closed the position; a cancelled or expired bracket closes the row without a trade outcome, since nothing sold; a broker error on one order never abandons the rest; and an exit with no position context is skipped rather than guessed, matching `record_closed_trade`. Mutation-checked: unwiring it from the cycle, treating partials as full exits, using the expected price, un-isolating the per-order error, and making cancel_order trust the HTTP response each fail a test. 961 passing, ruff and mypy clean, backtest baseline byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three opus reviewers over the rewritten branch. Two independently found the same functional break, which my own native-bracket rewrite introduced. Rule-driven exits could never fill. `place_bracket` leaves a bracket committing the entire base position, and `_handle_exits` then issued a full-size market SELL for that same inventory -- on spot the base is locked, so the sell is rejected, `position_rule` is never cleared, no outcome is recorded, and the agent retries the same doomed sell every cycle while the position rides a stale stop. The rewrite was validated against the path it changed and not against the path it left behind. `execute` now clears any resting bracket before a SELL, so every exit path gets it by construction, and refuses the exit outright if the bracket cannot be cancelled. `_cancel_at_exchange` discarded `cancel_order`'s return value, so a refused cancel was recorded as a successful one -- the exact lie the module exists to prevent. No test could catch it because every fake returned None. A partial-then-cancelled bracket silently discarded the filled portion, leaving `_held_position` reporting the full position held while the realized loss never reached rails 11 or 16. Two of my own ledger claims were false, and the reviewers refuted both. The cancel-first ordering was unheld: my test asserted a cancel HAPPENED, not that it happened FIRST, and place-then-cancel survived. The entry-fee wiring was unheld: every downstream test hand-seeded `entry_fee` into a fixture, so mutating the producer to stop writing it left the suite green -- silently reverting pnl_net to net-of-exit-fee-only. Also: stop/target state no longer leaks past a rule exit; `place_bracket` is the single writer of that pair; market orders now upgrade to observed economics so rail 16 stops counting two different definitions of pnl_net; a dead bracket on a held position escalates CRITICAL instead of INFO; `_record_fill` moved inside the per-order isolation; a zero-price fill no longer fabricates a phantom loss; four previously unheld guards are pinned; a tripwire test fails the moment `scale_out` is wired; and five docstrings that had become false were corrected. Independently verified rather than trusted: the bracket price mapping is correct against the installed SDK, and no test coverage was silently lost across the branch (656 -> 748 test defs, diffed per commit). 980 passing, ruff and mypy clean, backtest baseline byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seam B was built out of a review pass rather than a plan, which is what let a break in the rule-exit path go unnoticed. This document is the correction. Part A records what already landed as design decisions with their rationale and accepted trade-offs, to be reviewed against rather than executed -- retroactive TDD steps for passing code would be theatre. Part B is the genuinely unbuilt work in normal task form: persist the bracket order id, re-bracket a dead bracket, a per-tranche position ledger (the live blocker), get_order/cancel_order on the broker port, and a rail-11 producer for the simulator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntext Adds a v4 `positions` table, one row per tranche, carrying the entry context (`entry_fill`, `qty`, `entry_fee`, `rule_name`, `opened_at`) that `agent_state["position_rule:<product>"]` used to hold as a single per-product blob. That blob was last-write-wins, so averaging up overwrote the first tranche's entry and an older tranche's bracket booked its P&L against a price it never paid. `positions.bracket_order_id` is the one linkage direction, with a FK to `orders(id)`. `ExecutionResult` now surfaces `bracket_order_id` so `run_once` can point a tranche at its bracket -- `execute` places the bracket internally, so its return was the only way to learn the id. The per-product `agent_state["bracket_order:<product>"]` key added in b294f0e is dropped as superseded: it was write-only and duplicated this fact at the wrong granularity. Both exit paths are rewired, not just reconcile: `_handle_exits` closes every open tranche FIFO and records one outcome each, apportioning the exit order's single fee pro-rata by qty. Wiring only the reconcile path would have silently stopped voluntary exits from producing outcomes at all, since `record_closed_trade` skips a position with no `entry_fill`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`execution.reconcile` duck-typed both against `keel/data/cb_client.py`, a module broker-port Phase B deletes -- so reconciliation would have broken the moment Phase B landed. Adds `OrderStatus` to the port's result types and both methods to the `Broker` protocol, implemented on the Coinbase and Fake adapters. The conformance tests round-trip the order id through `place_order` rather than hardcoding one: the suite runs against both adapters, and a Coinbase-shaped magic id would test a fixture instead of the contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Third instance of the dormant-rail pattern: `keel simulate` could not trip rail 11, so its thresholds could not be swept. Nothing in the sim ever moved `drawdown_total_pct`/`drawdown_weekly_pct` off their 0 defaults, so `0 >= max_total_dd_pct` was never true whatever the sweep varied it to. `SimAccount.update_equity` is the missing producer -- the sim-side twin of `execution.equity.update_drawdown`, mirrored arithmetic step for step, called once per bar by `portfolio_sim` before signal evaluation. `can_open` gains the rail-11 check beside rail 16 (entries only, DCA exempt, `>=`, matching `guards.check`). Both legs are armed, not just the total one the plan named: a producer that left `max_weekly_dd_pct` dormant in the sim would recreate the exact defect class this task exists to close. `deposit` rebases the HWM and equity history itself, so a monthly contribution cannot ratchet the monotonic HWM into a phantom drawdown. Folding it into the single method that moves cash makes it a writer that cannot be forgotten -- unlike live, where the operator must remember `keel record-flow`. Sweep response curve for max_total_dd_pct (trade count): 0 -> 0 | 0.01 -> 7 | 0.02 -> 15 | 0.03 -> 22 | 0.04 -> 30 | 0.05 -> 38 0.06 -> 45 | 0.08 -> 61 | 0.10 -> 73 | 0.15 -> 73 | 0.20 -> 73 | 0.90 -> 73 Monotonic, saturating at the unhalted count of 73 once the threshold clears the scenario's ~9.6% actual drawdown -- the rail is genuinely tunable. Mutation check: commenting out the per-bar `update_equity` call fails exactly the two sweep tests and nothing else. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Jul 20, 2026
…handbook, Fib/pivots The compliance pair (66, 67) is the most consequential material since §28, and the two reconcile against each other: - §66 CORRECTS §56.1, which was stated too bluntly. "T+2 implies riba" is wrong as written: a T+2 window is compatible with spot provided real delivery concludes. The actual defect is perpetual rollover so delivery never happens. Delivery-concluding lag != non-delivery-by-design. - §66.2 grounds "a BUY tranche must be confirmed-settled before it is exit-eligible" in fiqh (the hadith forbidding resale before possession), not merely operational hygiene. PR #96's per-tranche ledger and reconciliation already implement it; the rationale is now recorded. - §66 logged custodial/digital qabd as an OPEN GAP. §67.1 CLOSES it: OIC Fiqh Academy Res. 53/4-6 holds electronic constructive possession via a custodial account satisfies qabd. Exchange custody is now sourced, not an interpretive stance. Cross-referenced both rows so the README does not contradict itself. - §67.2 extends the sarf/spot grounding from currency to GOLD, closing a PAXG-specific citation gap §30.1 never covered. - §66.1 triangulates bay' al-sarf across two more sources: the spot-settlement mandate now rests on four independent sources. - §66.7 NEGATIVE FINDING: no compliance source in this KB addresses crypto directly. Silence is not permission. Next compliance source should be a targeted AAOIFI/OIC digital-asset resolution. Also: Deve.pdf is a DUPLICATE of Source 23, confirmed by text diff rather than title match. Its 2026 stamp is the generator re-dating on download. Source 70: the Fibonacci booklet is promotional, not the debunking its title suggested. Its own admissions ("which level holds is unknowable"; anchor choice "becomes a guessing game") mildly reinforce the deferred-to-v2 judgement without settling it. Floor-trader pivots logged as an untested candidate whose formula-vs-S/R status §58.6/§58.9 genuinely does not resolve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Jul 20, 2026
…ill under-deployment claim Sources 75 (Cameron scanner/TA lead-magnets) and 76 (Chart Patterns v2) extracted; 77 (candlestick infographic + filled-in trading-plan worksheet) logged as an audit-trail-only exclusion, no file, per the source-53 precedent. Headline is a NEGATIVE structural result. Cameron's scanner is a BREADTH engine: its trade count comes from |universe| ~5,000, not from ranking. So SS60.2's 'allowlist-size-independent' claim does not survive -- ranking only bites when qualifying candidates outnumber slots, and at |allowlist| = 3 it never does. Under-deployment is a signal-PRODUCTION problem; ranking cannot produce signals. Orchestrator correction applied on top of the extraction: the demotion covers the RANKING half only. SS60.2's CONCURRENT-SLOT half is not a no-op, it is untestable -- PR #96 lifted the one-tranche-per-product limit on the live path while keel/sim/portfolio_sim.py:600 still enforces one RULE position per asset. That reframes the S1+S2 ensemble rejection as an artifact of line 600 rather than a finding about the ensemble. Module-map rows for money_mgmt/backtest and the SS60 log row all bounded to match. Refs: docs/superpowers/references/trading-knowledge-base/ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Executes
docs/superpowers/plans/2026-07-19-keel-native-bracket-and-reconciliation.mdend to end (Part B, Tasks 1–5). Part A had already landed on this branch and is documented in the plan as design decisions to review against.Goal: make keel's exit path trustworthy — the exchange owns the stop-vs-target race, every fill is observed and recorded, and every position is addressable per tranche rather than per product.
What's here
roll_to_break_even/trail_stop_atrstop being unreachable by construction.positionstable (schema v3→v4) replacesposition_rule:<product>as the carrier of entry context. Kills the last-write-wins attribution bug where an older tranche's bracket computed P&L against a newer tranche's entry price.get_order/cancel_orderon theBrokerport and both adapters, so broker-port Phase B can deletecb_client.pywithout breaking reconciliation.keel simulate.Judgement calls worth a reviewer's attention
place_orderrather than hardcoding the plan's"cb-1"/"already-filled". The suite also runs againstFakeAdapter, which has never heard of those ids — Coinbase-specific magic ids don't belong in a venue-agnostic contract.max_total_dd_pctandmax_weekly_dd_pct), not just the total leg the plan named. A producer that left the weekly leg dormant in the sim would recreate the exact defect class the task exists to close.Sweep response curve for
max_total_dd_pct(trade count), evidence the rail is genuinely tunable:0 → 0 | 0.01 → 7 | 0.02 → 15 | 0.03 → 22 | 0.04 → 30 | 0.05 → 38 | 0.06 → 45 | 0.08 → 61 | 0.10 → 73 | 0.20 → 73 | 0.90 → 73Verification
.venv/bin/python -m pytest -q→ 1015 passed.venv/bin/ruff check .→ clean.venv/bin/mypy .→ no issues, 148 source filestests/fixtures/baseline_backtest.jsonbyte-identical🤖 Generated with Claude Code