Skip to content

feat: the live per-cycle stop-management step, off by default (#502 stage 2) - #581

Merged
eaitbrahim merged 7 commits into
mainfrom
feat-502-bracket-spec
Aug 28, 2026
Merged

feat: the live per-cycle stop-management step, off by default (#502 stage 2)#581
eaitbrahim merged 7 commits into
mainfrom
feat-502-bracket-spec

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Partially addresses #502 (the two scope items that remained; see "What did NOT land" for the scale_out remainder).

The audit first: the recorded blocker is GONE

#560's block note said stage 2 was inseparable from #524 because the executor was not on the port at all (broker.place_order(product_id, side, order_configuration) — three positional args + a raw dict). #569 landed feat(executor): every order is an OrderSpec, and the second Coinbase renderer is gone (#524), and auditing origin/main at that commit found the blocker resolved and, with it, the first two scope items already shipped:

  • Bracket OrderSpec kind — LANDED. keel_broker_api.orders.BracketGTC (bracket_gtc) exists, __post_init__ refuses equal/inverted leg pairs, and keel_broker_coinbase.translate.to_order_configuration renders it to the venue-accepted trigger_bracket_gtc shape (pinned byte-for-byte in tests/broker_coinbase/test_translate.py). The other adapters refuse it out loud: alpaca and robinhood raise UnsupportedOrder at translation naming the capability gap ("not translated yet" vs "the venue has no bracket/OCO order type"), kraken and fake leave it undeclared in capabilities; the conformance suite's test_the_bracket_declaration_cannot_lie_in_either_direction pins both directions (declared => accepted, undeclared => UnsupportedOrder, never a two-leg synthesis).
  • Executor bracket through the port — LANDED. _bracket_order_configuration is gone; _bracket_spec builds a BracketGTC value and place_bracket/_roll_stop hand it to broker.place_order(spec) through _run_order. No venue dict is built in keel/ anymore (grep trigger_bracket_gtc keel/ is empty), and tests/execution/test_executor.py pins the placed spec is a BracketGTC (lines ~888, ~1617).

So this PR is the part the issue actually opened for: the live wiring, plus two executor enablers it needed.

What lands here

1. roll_stop_to + the roll keeps its tranche link (a16d5bb)

2. The per-cycle live stop-management step, off by default (0a56727)

agent.run_once now calls _manage_stops after the trading decisions, before cycle end (live cycles only — paper places no exchange brackets). Per open tranche whose owning rule carries trail_atr_mult / be_roll_rr:

Defaults OFF, pinned. A rule whose params carry neither knob gets EXIT_POLICY_OFF and its position is untouched — the identity test pins no cancel at the broker, the resting bracket unchanged, levels unchanged. That is every rule row in existence today. The #442 experiment (docs/experiments/2026-08-22-trailing-vs-static-exits.md) measured trailing WORSE and the break-even roll no better than the static exit at the 120 bp fee, so the capability ships dark and the operator opts in per rule; turtle stays Donchian by choice (also pinned). A tranche without a recorded initial_stop (#520's NULL contract) runs with the BE arm disabled rather than substituting the current stop.

Also updated the docstrings that claimed the knobs were sim-only (exit_policy, both rule families' PARAM_DOCS, paper.py, portfolio_sim.py) and the executor/reconcile comments claiming the port has no bracket kind.

What did NOT land (recorded on the issue)

scale_out stays unwired and the tripwire (test_scale_out_has_no_production_caller) stays. Its prerequisites are a real choreography stage, not a leftover — precise remainder posted as an issue comment.

Overlap with #524's follow-on work

keel/execution/executor.py overlaps the registry-resolution/grandfather-clause work happening in parallel on #524. This PR's hunks there are tight: the module docstring's stop-management block, one guard comment, the _roll_stop success-path repoint, and the new roll_stop_to wrapper. No signature or dispatch changes.

Test delta

+9 tests (2 executor, 7 agent), 4310 passed / 3 skipped, ruff check keel tests packages clean, mypy clean (347 files). ruff format --check on touched files reports only pre-existing drift already on main under this ruff (PEP 758 paren-dropping — main itself would reformat 128 files); no new deviations introduced.

…tranche link (#502)

`roll_stop_to` is the general form of the two named roll primitives: one explicit
level, one cancel-and-replace. The agent's live stop-management step needs exactly
that shape -- a policy carrying both exit knobs can win on both in one cycle, and
calling `roll_to_break_even` and `trail_stop_atr` back-to-back would walk #519's
cancel-before-place window twice against the same position for no benefit.

`_roll_stop` now also repoints the owning tranche at the replacement bracket.
`get_position_for_bracket` is reconciliation's one linkage direction; until rolls
were reachable the link could not go stale here, but a roll that cancels the
bracket a tranche names and leaves the name behind orphans the replacement -- its
eventual fill resolves to no tranche, the trade_outcomes row is dropped, and rail
16 miscounts a managed winner.

Also brings the module docstring and #560's guard comment up to the post-#569
reality they still described falsely (the live path DOES build a BracketGTC now;
the 'no live caller' claim about stop management is what the next commit changes).
…502)

`run_once` now manages held positions' exchange brackets: after the trading
decisions, before cycle end, `_manage_stops` walks the open tranches and, for each
whose owning rule carries `trail_atr_mult` / `be_roll_rr`, computes the ratcheted
level with the SAME policy the sim/backtest engines apply (`policy_for` +
`next_stop` on the latest COMPLETED bar -- poll stores closed candles only, so the
no-lookahead contract holds) and rolls the resting bracket ONCE via
`executor.roll_stop_to` (#519's cancel-before-place protocol, reused, not
re-invented).

DEFAULT OFF, exactly like the sim wiring: a rule whose params carry neither knob is
never touched, and every rule row in existence carries neither -- pinned by the
identity test. The #442 experiment measured trailing WORSE and the break-even roll
no better than the static exit at the 120 bp fee, so the capability ships dark and
the operator opts in per rule; turtle stays Donchian by choice. Paper cycles never
run the step (no exchange brackets to roll), and a tranche without a recorded
initial_stop runs with the break-even arm disabled -- the ledger's own contract.

Tests: the defaults-off identity pin, a trailing roll through the fake broker
(level pinned to the policy's own arithmetic, cancel-before-place asserted, tranche
repointed), a falling cycle that never widens, the break-even roll and its
NULL-initial_stop disable, turtle never managed, paper never managed.

Docstrings that claimed the knobs were sim-only (`exit_policy`, both rule
families' PARAM_DOCS, paper, portfolio_sim) and #560-era claims that the port has
no bracket kind (executor, reconcile) are brought up to date.
…502)

The rules table holds one row per (kind, product) and Rule.name is the FAMILY
name, so the step's name-keyed dict governed a multi-product family's every
tranche under whichever same-family row loaded last: an opted-in BTC row
managing a knob-less ETH tranche's bracket, or the reverse. Key by
(product_id, name) and look up (tranche.product_id, tranche.rule_name) -- the
same product scoping _handle_exits uses.

Pinned by test_a_family_row_only_manages_the_tranches_of_its_own_product:
two same-family rows with different knobs on different products; the
knob-less product's tranche sees no cancel/place, the opted-in product's does.
)

The per-cycle step called executor.roll_stop_to bare, so a reachable cancel
failure (CancelPending/CancelUnavailable -- ordinary Coinbase batch-cancel
outcomes when a fill lands during the roll) aborted run_once AFTER entries
were placed: no LoopResult, no post-cycle notify, a nonzero CLI exit, and the
live-run wrapper declining to stamp the UTC day -- the next trigger re-runs
the cycle into the duplicate-entry window. Wrap each tranche's roll in a
deliberately broad except: log CRITICAL with the traceback attached
(keel-core's log_exception grows a level kwarg so the severity is not bought
by dropping the stack) and continue to the next tranche.

Pinned by test_a_failed_roll_is_loud_and_isolated_never_a_dead_cycle: a
broker whose cancel_order raises still yields a LoopResult, the later
tranche is still managed, the crash ledger stands for the sweep, and the
CRITICAL agent.stop_management_roll_failed is on record.
…ent (#502)

Minors from review, plus the unpinned paths:

- Young-table warmup: the trail level only matches the #442-measured sim
  behavior once the table holds 4*atr_period+1 closed bars (poll_once
  cold-starts at ONE bar; backfill has no production caller), so the trail
  arm is off short of that; the BE arm is NOT equally affected (it reads
  only the latest bar's high) and stays live. The waiting notice is INFO,
  once per product, not per cycle.
- Stale-product skip: the comment claimed 'no completed bar', which is wrong
  (the table may hold bars; the cycle fetched none) and the skip was silent;
  it now logs at the empty-TF case's INFO volume.
- Kill-switch window documented on _roll_stop/_manage_stops: a cancel is not
  rail-gated, so a switch engaging mid-roll fails only the replacement
  (rail 12) and leaves the position unbracketed until it clears; the
  CRITICAL is loud, reconcile heals when cycles resume.
- The open_stop read inside the per-tranche loop now says out loud that it
  assumes one tranche per asset; per-tranche keying is a pyramiding
  prerequisite. _management_timeframe's docstring names its bounded
  divergence from backtest's ONE_HOUR fallback.
- Pins: the dict-miss skip (no fallback to a row the tranche does not own)
  and the empty-timeframe skip (with its INFO event); a both-knobs test
  proving exactly ONE roll per cycle; the duplicated _seed_history call
  dropped. The 57-bar fixtures sit at the warmup threshold, past the
  at/above-target refusal's reach.
@eaitbrahim
eaitbrahim merged commit 16725c1 into main Aug 28, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-502-bracket-spec branch August 28, 2026 03:01
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.

1 participant