fix(ledger): carry the owning rules.id on a position so exits are attributed (#803) - #805
Merged
Merged
Conversation
…ributed 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
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
force-pushed
the
fix/803-exit-attribution
branch
from
September 16, 2026 19:22
8f9e7a6 to
3fe8446
Compare
… 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
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>
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 #803.
The bug
OrderIntentcarries two different facts in two fields (guards.py:194-200):rule_kind, aKIND (
"dca","turtle_breakout"), andrule_id, therules.idrow. Entries thread the idfrom
signal.rule_id. Both exit paths build their intent from a POSITION, not a signal — andpositionsstored only the kind, so there was no id to thread and every protective SELL waswritten with
orders.rule_idNULL.Not cosmetic: per-rule accounting reading
orders.rule_idsees entries attributed and exitsanonymous, which for a trend rule drops precisely the leg the outcome lands on.
Not caused by the demotion, which is the natural first guess — rule 3 went
live -> papershortly before this order, which looks exactly like a lookup that stopped resolving. The id was
never on the intent; the row would read
unattributedwith the rule live. The migrationdocstring records that, so the next reader doesn't re-derive it.
The change
positions.rule_id INTEGER, nullable._open_trancheinherits it from the ENTRY order rather than taking a new argument. Thatorder was already written with
signal.rule_id, so every caller gets the attribution withoutone more parameter to forget.
place_bracketandscale_outtake an optionalrule_idand put it on the intent; the fourcall sites pass
position["rule_id"]orsignal.rule_id.No backfill
The call v12 made for
initial_stop. A tranche records its bracket order, never itsentry order, so there is no join back to the id. Matching on
(product_id, rule_name)wouldre-attribute by guess and be wrong wherever a rule row was replaced. NULL means "nobody recorded
it", and there is a test asserting a pre-v21 row reads NULL rather than acquiring an owner.
rule_idis optional everywhere, deliberatelyThere is a test for that on its own: a missing id must never cost a position its stop. A
pre-v21 tranche has none, and refusing to protect it because the bookkeeping is incomplete would
trade a reporting gap for an unprotected position.
Verification
Mutation: dropping
rule_id=rule_idfromplace_bracket's intent — which puts live order id 5back in exactly the state that prompted this — fails the end-to-end test and nothing else.
Nine hardcoded
SCHEMA_VERSION == 20pins across three test files moved to 21, and three newmigration tests cover the fresh schema, the no-backfill path, and idempotency.
Note for the deployment
This is a schema change:
keel migratemust run on all four databases before the new buildtrades. The ALTER is guarded by
PRAGMA table_infoand adds a nullable column, so it isnon-destructive and idempotent.
Stacked behind #804 (#802) in spirit but independent in code — they touch different files and can
land in either order.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD