Skip to content

feat(db): record the stop a tranche was sized against (#520, #502 stage 2) - #527

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-520-initial-stop
Aug 24, 2026
Merged

feat(db): record the stop a tranche was sized against (#520, #502 stage 2)#527
eaitbrahim merged 1 commit into
mainfrom
feat-520-initial-stop

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #520. First piece of #502 stage 2 — the named prerequisite for the live break-even arm.

The gap

exit_policy.next_stop's break-even arm computes its threshold from the trade's original per-unit risk:

entry + be_roll_rr × (entry − initial_stop)

Live state carried entry_fill (the ledger) and open_stop:<product_id> (the current, already-ratcheted stop) — and nothing else. The number the threshold is most sensitive to was simply absent.

Why substituting the current stop is wrong, not merely imprecise

The current stop rises on every ratchet, shrinking (entry − stop). So the threshold creeps toward entry and the arm fires earlier each time, drifting further from the measured policy the longer a trade runs.

Live and sim would then encode two different break-even rules while appearing to share exit_policy's functions — the exact failure that sharing them was meant to prevent.

What ships

  • v12 migration adding positions.initial_stop, idempotent by the v8/v11 PRAGMA table_info guard (a database stamped at v11 got positions from v4's DDL, and CREATE TABLE IF NOT EXISTS never adds a column).
  • Written at tranche open by agent._open_tranche from the entry setup's stop, and never rewritten — that separation from the ratcheting open_stop: is the fix.
  • Repository.open_position takes it; _position_row_to_dict decodes it nullable.

The nullable handling is load-bearing

None means UNKNOWN, and readers must switch the break-even arm off for that tranche rather than guess.

Zero would be a real number — a stop 100% below entry — and a catastrophic one to compute a threshold from. So _position_row_to_dict is careful not to let _text_to_dec invent one, and there's a test named for it.

The trailing arm is unaffected either way: it needs no original risk, so a tranche without one keeps trailing and simply never break-even-rolls.

No backfill, deliberately. The honest value for every pre-v12 tranche is NULL. Inventing one would fabricate the single input the policy is most sensitive to.

DCA passes None, and that is legitimate rather than degraded — it carries no stop by design, and requiring one would refuse a real tranche.

Tests

Six, in tests/data/test_initial_stop.py:

  • round-trips as a Decimal;
  • unrecorded reads as None, not zero (the load-bearing one);
  • DCA-style open with no stop is accepted, not rejected;
  • the column exists on a fresh migrate;
  • the migration is idempotent and does not backfill;
  • initial_stop is not rewritten when the live stop ratchets.

The three schema-version tripwires were bumped 11 → 12 consciously, which is what they exist to force.

Verification

  • pytest -q4,559 passed, 3 skipped
  • ruff check keel tests packages — clean
  • mypy — clean, 354 source files

Not in this PR

The rest of stage 2: idempotency keys on bracket placement, the roll protocol's pre-flight guards and materiality floor, the executor constructing BracketGTC values, and the scale_out rebuild. The crash-ledger half of the roll protocol already shipped as #522.

There is no live consumer of this column yet — the break-even arm itself is stage 3. Same shape as stage 1: the data exists and is correct before anything depends on it.

…rrent one ratchets away from it (#520)

v12 adds positions.initial_stop, written at tranche open and never rewritten.

The break-even arm of exit_policy.next_stop computes its threshold from the
trade's ORIGINAL per-unit risk: entry + be_roll_rr * (entry - initial_stop). Live
state carried entry_fill (the ledger) and open_stop:<product_id> (the CURRENT,
already-ratcheted stop) and nothing else -- so the number that threshold is most
sensitive to was simply absent.

Substituting the current stop is not an approximation, it is a DIFFERENT POLICY.
The current stop rises on every ratchet, shrinking (entry - stop), so the
threshold creeps toward entry and the arm fires earlier each time, drifting
further from the measured policy the longer a trade runs. Live and sim would then
encode two different break-even rules while appearing to share exit_policy's
functions -- the exact failure sharing them was meant to prevent.

NULL means UNKNOWN and readers must switch the break-even arm OFF for that
tranche rather than guess. Zero would be a real number -- a stop 100% below entry
-- and a catastrophic one to compute a threshold from, so the column is nullable
and _position_row_to_dict is careful not to let _text_to_dec invent a zero. The
trailing arm is unaffected either way: it needs no original risk, so a tranche
without one keeps trailing and simply never break-even-rolls.

NO BACKFILL, deliberately: the honest value for every pre-v12 tranche is NULL.
Idempotent by the v8/v11 PRAGMA table_info guard, because a database stamped at
v11 got positions from v4's DDL and CREATE TABLE IF NOT EXISTS never adds a
column.

DCA passes None and that is legitimate, not a degraded case -- it carries no stop
by design, and requiring one would refuse a real tranche.

The three schema-version tripwires were bumped 11 -> 12 consciously, which is
what they exist to force.

Gates: pytest 4559 passed / 3 skipped; ruff check keel tests packages clean; mypy
clean across 354 source files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
@eaitbrahim
eaitbrahim merged commit 4804791 into main Aug 24, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat-520-initial-stop branch August 24, 2026 05:47
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.

positions.initial_stop is not persisted, so the live break-even roll is uncomputable

1 participant