Skip to content

fix(ledger): carry the owning rules.id on a position so exits are attributed (#803) - #805

Merged
eaitbrahim merged 3 commits into
mainfrom
fix/803-exit-attribution
Sep 16, 2026
Merged

eaitbrahim merged 3 commits into
mainfrom
fix/803-exit-attribution

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #803.

The bug

SELL   PAXG-USD   rejected   unattributed   0.01320427

OrderIntent carries two different facts in two fields (guards.py:194-200): rule_kind, a
KIND ("dca", "turtle_breakout"), and rule_id, the rules.id row. Entries thread the id
from signal.rule_id. Both exit paths build their intent from a POSITION, not a signal — and
positions stored only the kind, so there was no id to thread and every protective SELL was
written with orders.rule_id NULL.

Not cosmetic: per-rule accounting reading orders.rule_id sees entries attributed and exits
anonymous
, 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 -> paper
shortly before this order, which looks exactly like a lookup that stopped resolving. The id was
never on the intent; the row would read unattributed with the rule live. The migration
docstring records that, so the next reader doesn't re-derive it.

The change

  • v21: positions.rule_id INTEGER, nullable.
  • _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.
  • place_bracket and scale_out take an optional rule_id and put it on the intent; the four
    call sites pass position["rule_id"] or signal.rule_id.

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", and there is a test asserting a pre-v21 row reads NULL rather than acquiring an owner.

rule_id is optional everywhere, deliberately

There 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_id from place_bracket's intent — which puts live order id 5
back in exactly the state that prompted this — fails the end-to-end test and nothing else.

Nine hardcoded SCHEMA_VERSION == 20 pins across three test files moved to 21, and three new
migration tests cover the fresh schema, the no-backfill path, and idempotency.

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

Note for the deployment

This is a schema change: keel migrate must run on all four databases before the new build
trades. The ALTER is guarded by PRAGMA table_info and adds a nullable column, so it is
non-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

eaitbrahim and others added 2 commits September 16, 2026 15:12
…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
eaitbrahim force-pushed the fix/803-exit-attribution branch from 8f9e7a6 to 3fe8446 Compare September 16, 2026 19:22
… 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
eaitbrahim merged commit 62df026 into main Sep 16, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/803-exit-attribution branch September 16, 2026 21:26
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>
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.

Exit and bracket orders are recorded unattributed -- orders.rule_id is only ever set on entries

1 participant