Skip to content

fix(execution): quantize bracket prices to the venue tick, in opposite directions (#802) - #804

Merged
eaitbrahim merged 2 commits into
mainfrom
fix/802-bracket-price-tick
Sep 16, 2026
Merged

eaitbrahim merged 2 commits into
mainfrom
fix/802-bracket-price-tick

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #802.

The bug

Sizes were quantized (#513/#516). Prices were not.

2026-09-16 03:44Z, keel-live.db order id 5
executor.order_rejected  PAXG-USD SELL  error='Too many decimals in order price'

_bracket_spec floored base_size and passed stop/target straight through, so
4521.76390215979454 went on the wire. sizing.quote_increment_for looks like it covers this
and does not — its own docstring says "the venue's finest acceptable quote_size", it is
keyed 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. Nothing
distinguished that from DCA's legitimate bracket-lessness, in any surface.

Direction is the substance, not a detail

quantize_down is documented as the safe direction for sizes — rounding a size up spends
more than guards.check authorised. 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:

price direction why
stop_trigger_price up toward safety — never widen the risk the position was sized on
take_profit_price down toward reachability

quantize_up is a separate function, not a flag on quantize_down, because the two are
justified 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, with
a 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 BracketPricesUnplaceable rather than reaching the venue.

And the #799 shape is closed while we're here

_bracket_spec was an inline argument to _run_order, so anything it raised left
place_bracket past the if not result.placed recovery — exactly how a filled PAXG entry was
stranded for three weeks. It is now built inside a try, and a bracket that cannot be BUILT is
the same event as one the venue REFUSES
: levels recorded to unbracketed:, a WARNING, a retry
next 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_down for the stop — the fix that still puts prices on the tick and
would pass a naive "is it rounded?" check — is killed by three tests.

ruff check keel tests packages   -> All checks passed!
ruff format --check .            -> 503 files already formatted
mypy                             -> clean (465 files; pre-existing PyYAML stub note only)
pytest                           -> 6651 passed, 3 skipped

Deployment note

The live PAXG position is currently retrying its bracket every cycle and escalating
reconcile.position_unprotected at CRITICAL. This is what lets that retry succeed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD

…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
eaitbrahim merged commit 7fd2d67 into main Sep 16, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/802-bracket-price-tick branch September 16, 2026 19:11
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>
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.

Bracket prices are never quantized to the venue's price tick -- every protective bracket is rejected

1 participant