fix(execution): quantize bracket prices to the venue tick, in opposite directions (#802) - #804
Merged
Merged
Conversation
…e directions Sizes were quantized (#513/#516); prices were not. `_bracket_spec` floored `base_size` and passed `stop`/`target` through untouched, so the rule's ATR arithmetic reached the wire at fourteen decimal places and Coinbase rejected it: 2026-09-16 03:44Z, keel-live.db order id 5 executor.order_rejected PAXG-USD SELL error='Too many decimals in order price' Every protective bracket was refused, which is why every position in that deployment carried `bracket_order_id = NULL` -- the stop-loss and take-profit machinery has never fired in live, and nothing distinguished that from DCA's legitimate bracket-lessness. Direction is the substance. `quantize_down` is documented as safe FOR SIZES -- rounding one up spends more than the rails authorised -- and that reasoning does not transfer: a long's protective stop rounded DOWN sits further from price and silently widens the loss the position was sized against. So the stop rounds UP and the target DOWN, each toward the safer answer, which means toward each other. `quantize_up` is a separate function rather than a flag on the existing one, because the two carry different arguments and a flag invites picking the wrong one. Because they move toward each other, a coarse tick can invert a pair that was valid before it. That is `BracketPricesUnplaceable` -- raised rather than sent. `_bracket_spec` was an inline ARGUMENT to `_run_order`, so anything it raised escaped `place_bracket` past the `if not result.placed` recovery: the shape that stranded a filled position in #799. It is now built in a `try`, and a bracket that cannot be BUILT takes the same path as one the venue REFUSES -- levels to `unbracketed:`, a WARNING, and a retry next cycle. `Instrument.quote_increment` is the field its own docstring anticipated ("would sit here naturally, but nothing reads them yet"). It rides the existing per-product fetch and the existing cache record, so no extra venue round-trip enters the order path. Unknown stays unknown: prices go unrounded, never refused, because refusing leaves a filled position with no stop at all. Verified by mutation: reusing `quantize_down` for the stop -- the fix that still puts prices on the tick -- is killed by three tests. Refs #802. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD
…nd call site Self-review of #804 found `_bracket_spec` has TWO callers and this PR patched one. `_roll_stop` -- the trailing ratchet, the protective path that fires most often -- kept sending prices at full Decimal precision, so #802 would have stayed alive on it while `place_bracket` looked fixed. It also shares the #799 escape shape, and worse: `_roll_stop` CANCELS the old bracket before building the replacement, so an exception there leaves the position naked AND skips the CRITICAL that exists to announce exactly that. The spec is now built in a try, and a replacement that cannot be BUILT logs the same CRITICAL as one the venue REJECTS, leaving the `unbracketed:` record standing for the sweep. Tests added for the gap the review also found: `_price_increment_for` had no direct coverage. Five cases -- warm cache, one-fetch-for-both, a pre-#802 record with no quote_increment key, a venue reporting no tick, and no broker at all (paper mode). NOT changed, recorded so it is not re-attempted: the `except (A, B) as exc` parentheses look inconsistent with `keel/proposer.py:53`'s bare PEP 758 form, but the unparenthesized syntax is illegal WITH an `as` binding -- "multiple exception types must be parenthesized when using 'as'". The two forms are not interchangeable. Refs #802. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD
eaitbrahim
added a commit
that referenced
this pull request
Sep 16, 2026
Reviewing #804 turned up a second `_bracket_spec` call site that PR had missed; the same question asked of this one found the same shape. `_roll_stop` builds its own `OrderIntent` and carried `rule_kind` with no `rule_id`, so every ratcheted stop -- the protective order that fires most often on a running trend trade -- would still have landed unattributed while `place_bracket` and `scale_out` looked fixed. Resolved inside `_roll_stop` rather than threaded through its three public wrappers (`roll_to_break_even`, `trail_stop_atr`, and the bare roll). The function already holds `old_stop_order_id`, the bracket the tranche currently names, and the success path already calls `get_position_for_bracket` on it to repoint the tranche -- so the owner is one read away from the ledger, which is a better source than an id passed down three signatures. The read is placed BEFORE the cancel-and-replace: afterwards the tranche names the replacement and the old id resolves to nothing. All three `OrderIntent` constructions in the module now carry `rule_id`, verified by a brace-matched scan rather than by eye. Refs #803. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD
eaitbrahim
added a commit
that referenced
this pull request
Sep 16, 2026
…ributed (#803) (#805) * fix(ledger): carry the owning rules.id on a position so exits are attributed Live order id 5 -- the bracket for the turtle_breakout PAXG tranche -- rendered `unattributed` in the console because `orders.rule_id` was NULL. Not a lookup that failed. `OrderIntent` carries `rule_kind` (a KIND: "dca", "turtle_breakout") and `rule_id` (the `rules.id` row) as separate fields. Entries thread the id from `signal.rule_id`; BOTH exit paths build their intent from a POSITION, and `positions` stored only the kind -- so there was no id to thread and every protective SELL was written anonymous. It would read `unattributed` with its rule live; the demotion that preceded it is a coincidence, and the migration says so. The cost is not cosmetic: per-rule accounting reading `orders.rule_id` saw entries attributed and exits anonymous, which for a trend rule drops precisely the leg the outcome lands on. v21 adds `positions.rule_id`. `_open_tranche` inherits it from the ENTRY order rather than taking a new argument -- that order was already written with `signal.rule_id`, so every caller gets the attribution without one more parameter to forget. NO BACKFILL, the call v12 made for `initial_stop`: a tranche records its BRACKET order, never its ENTRY order, so there is no join back to the id. Matching on `(product_id, rule_name)` would re-attribute by guess and be wrong wherever a rule row was replaced. NULL means "nobody recorded it". `rule_id` stays optional everywhere it is threaded, and there is a test for why: a missing id must never cost a position its stop. Trading a reporting gap for an unprotected position would be the worse bargain. Refs #803. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD * fix(ledger): attribute the ROLLED bracket too -- the third exit path Reviewing #804 turned up a second `_bracket_spec` call site that PR had missed; the same question asked of this one found the same shape. `_roll_stop` builds its own `OrderIntent` and carried `rule_kind` with no `rule_id`, so every ratcheted stop -- the protective order that fires most often on a running trend trade -- would still have landed unattributed while `place_bracket` and `scale_out` looked fixed. Resolved inside `_roll_stop` rather than threaded through its three public wrappers (`roll_to_break_even`, `trail_stop_atr`, and the bare roll). The function already holds `old_stop_order_id`, the bracket the tranche currently names, and the success path already calls `get_position_for_bracket` on it to repoint the tranche -- so the owner is one read away from the ledger, which is a better source than an id passed down three signatures. The read is placed BEFORE the cancel-and-replace: afterwards the tranche names the replacement and the old id resolves to nothing. All three `OrderIntent` constructions in the module now carry `rule_id`, verified by a brace-matched scan rather than by eye. Refs #803. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD * fix(ledger): test the inheritance line, and say why positions.rule_id has no FK Self-review found the PR's most important line completely untested. Every #803 test passed `rule_id=` to `repo.open_position` directly, so nothing exercised `agent._open_tranche`'s `rule_id=order.get("rule_id")` -- the one place production actually populates the column. Replacing it with `None` left 1872 tests green. `test_run_once_writes_the_rules_db_id_onto_the_POSITION_too` is the sibling of the existing ORDER test and kills that mutant. It is the assertion that matters: both exit paths build their intent from a POSITION, so a NULL there puts every protective SELL back to "unattributed" no matter what the entry recorded. Also documents what the review flagged as an inconsistency and is in fact a deliberate choice: `positions.rule_id` has NO foreign key while `orders.rule_id` and both attestation tables declare one. SQLite cannot carry a constraint through `ALTER TABLE ... ADD COLUMN`, so a database reaching v21 by migration physically cannot have one, and declaring it on the fresh DDL alone would leave fresh and migrated deployments enforcing different rules -- diverging silently, surfacing as an IntegrityError on one machine and not another. Recorded in both the DDL comment and the migration docstring, with the instruction that readers treat the id as a hint that may name a deleted row. Refs #803. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Sep 16, 2026
…eir rule (#806) MINOR, not a patch, because #805 ships schema v21: `positions.rule_id`. A deployment must run `keel migrate` on every database before the new build trades, and the version is the only signal an operator gets before reading the notes. What lands: #804 (#802) -- bracket prices quantized to the venue tick, stop UP and target DOWN. Until now every protective bracket was rejected with "Too many decimals in order price", so the stop-loss and take-profit machinery had never fired on the live deployment at all. Nothing distinguished that from DCA's legitimate bracket-lessness. #805 (#803) -- `positions.rule_id`, so the protective SELL that exits a tranche names the rule that owns it. Both exit paths build their intent from a position, so every bracket, roll and scale-out was written unattributed while its entry carried the name. Eight workspace versions and the fourteen inter-package `==` pins move together, plus the refreshed `uv.lock` the workflow checks first. Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Sep 17, 2026
…ot an "unknown" (#807) #802 shipped inert on the deployment it was cut for. `base_increment:PAXG-USD` had been written 24 hours before the upgrade by a build that did not know to ask for the price tick, and the 7-day TTL counted it FRESH -- so `_price_increment_for` read the absent key as "unknown", prices went out unrounded, and the venue rejected the bracket exactly as it had before. The position stayed unprotected, and would have for up to a week. The first cut's own docstring called that behaviour correct. It is correct only in the narrow sense that nothing knew the tick; operationally it waits out a TTL on data already known to be incomplete. The obvious fix -- refetch whenever the key is missing -- has a trap that is worse than the bug: a product whose venue genuinely reports no tick would refetch on EVERY cycle, putting a venue round-trip back into the order-placement path this cache exists to keep out of it. So the writer now records `quote_increment` ALWAYS, explicitly null when the venue reports none, and absence means one thing only: written before anyone asked. `_base_increment_for` grew `force_refresh` rather than having the caller delete the row first. Deleting is the shorter way to force a miss and the wrong one -- a venue call that then failed would have discarded a perfectly good `base_increment` and put SELL sizes back on the wire unquantized, which is #513. One test from #804 asserted the old behaviour and has been replaced rather than deleted: it now pins the narrower claim that survives -- a value present and unparseable is unknown, and is not repaired by guessing the common tick. Verified by mutation on both halves independently: reverting the read guard fails two tests, writing the key only when non-null fails the third. Refs #802. Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD Co-authored-by: Claude Opus 5 (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.
Closes #802.
The bug
Sizes were quantized (#513/#516). Prices were not.
_bracket_specflooredbase_sizeand passedstop/targetstraight through, so4521.76390215979454went on the wire.sizing.quote_increment_forlooks like it covers thisand does not — its own docstring says "the venue's finest acceptable
quote_size", it iskeyed on the CURRENCY not the product, and it is applied in one place: a BUY's notional.
Every protective bracket was rejected. All three positions on the live deployment carry
bracket_order_id = NULL, and the stop-loss/take-profit machinery has never fired there. Nothingdistinguished that from DCA's legitimate bracket-lessness, in any surface.
Direction is the substance, not a detail
quantize_downis documented as the safe direction for sizes — rounding a size up spendsmore than
guards.checkauthorised. That reasoning does not transfer to a protective stop:rounding a long's stop DOWN moves it further from price and widens the loss the position was
sized against, silently, on every bracket.
So:
stop_trigger_pricetake_profit_pricequantize_upis a separate function, not a flag onquantize_down, because the two arejustified by different arguments and a flag invites picking the wrong one by habit.
Note
Decimal.__floordiv__truncates toward zero rather than flooring, so the usual-((-v) // i * i)ceiling idiom silently returns the floor. It steps up explicitly instead, witha comment saying why.
A collapsed pair is refused, not sent
The two prices round toward each other, so a coarse tick can invert a pair that was valid before
it. That raises
BracketPricesUnplaceablerather than reaching the venue.And the #799 shape is closed while we're here
_bracket_specwas an inline argument to_run_order, so anything it raised leftplace_bracketpast theif not result.placedrecovery — exactly how a filled PAXG entry wasstranded for three weeks. It is now built inside a
try, and a bracket that cannot be BUILT isthe same event as one the venue REFUSES: levels recorded to
unbracketed:, a WARNING, a retrynext cycle. There is a test asserting the retry record holds the RULE's unrounded levels.
Where the tick comes from
Instrument.quote_increment, the field its own docstring anticipated ("would sit here naturally,but nothing reads them yet, and a field no caller reads is a field no test meaningfully checks").
It rides the existing per-product fetch and the existing cache record, so no extra venue
round-trip enters the order-placement path. A pre-#802 cache record simply reads as unknown until
it expires, which is correct — nothing knew the tick when it was written.
Unknown stays unknown: prices go unrounded, never refused. Refusing would leave a filled position
with no stop at all, which is the #799 outcome.
Verification
Mutation: reusing
quantize_downfor the stop — the fix that still puts prices on the tick andwould pass a naive "is it rounded?" check — is killed by three tests.
Deployment note
The live PAXG position is currently retrying its bracket every cycle and escalating
reconcile.position_unprotectedat CRITICAL. This is what lets that retry succeed.🤖 Generated with Claude Code
https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD