feat: the live per-cycle stop-management step, off by default (#502 stage 2) - #581
Merged
Conversation
…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.
…ts; nits from review
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.
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 landedfeat(executor): every order is an OrderSpec, and the second Coinbase renderer is gone (#524), and auditingorigin/mainat that commit found the blocker resolved and, with it, the first two scope items already shipped:keel_broker_api.orders.BracketGTC(bracket_gtc) exists,__post_init__refuses equal/inverted leg pairs, andkeel_broker_coinbase.translate.to_order_configurationrenders it to the venue-acceptedtrigger_bracket_gtcshape (pinned byte-for-byte intests/broker_coinbase/test_translate.py). The other adapters refuse it out loud: alpaca and robinhood raiseUnsupportedOrderat 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'stest_the_bracket_declaration_cannot_lie_in_either_directionpins both directions (declared => accepted, undeclared =>UnsupportedOrder, never a two-leg synthesis)._bracket_order_configurationis gone;_bracket_specbuilds aBracketGTCvalue andplace_bracket/_roll_stophand it tobroker.place_order(spec)through_run_order. No venue dict is built inkeel/anymore (grep trigger_bracket_gtc keel/is empty), andtests/execution/test_executor.pypins the placed spec is aBracketGTC(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)roll_to_break_evenandtrail_stop_atreach perform their OWN cancel-and-replace; a rule carrying both knobs can win on both in one cycle, and calling the two named primitives back-to-back would walk _roll_stop: a crash between cancel and replace leaves the position naked AND silent #519's cancel-before-place window TWICE against the same position for no benefit. The agent step computes one ratcheted level and hands it here._roll_stopnow repoints the owning tranche at the replacement bracket (get_position_for_bracket->set_position_bracket). Until rolls were reachable this link could not go stale in a roll, 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, thetrade_outcomesrow is dropped, and rail 16 miscounts a managed winner. This was a hard prerequisite for wiring the step.2. The per-cycle live stop-management step, off by default (
0a56727)agent.run_oncenow calls_manage_stopsafter the trading decisions, before cycle end (live cycles only — paper places no exchange brackets). Per open tranche whose owning rule carriestrail_atr_mult/be_roll_rr:policy_for+next_stop(strategy/exit_policy.py) on the latest COMPLETED bar of the rule's trading timeframe (poll_oncestores closed candles only, so the no-lookahead contract holds at cycle cadence);executor.roll_stop_to— _roll_stop: a crash between cancel and replace leaves the position naked AND silent #519's protocol (crash ledger before the venue is touched, cancel before place, bothRESTING_STATUSESfrom C6 — Partial fills are not tracked as a distinct state #446, the fix(executor): refuse a stop roll that reaches the target, and record why #502 stage 2 is blocked #560 at-or-above-target refusal) is reused, not re-invented;next_stopis ratchet-only by construction, and_roll_stoprefuses any widening beforeguards.checkever runs.Defaults OFF, pinned. A rule whose params carry neither knob gets
EXIT_POLICY_OFFand 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 recordedinitial_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_outstays 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.pyoverlaps 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_stopsuccess-path repoint, and the newroll_stop_towrapper. No signature or dispatch changes.Test delta
+9 tests (2 executor, 7 agent), 4310 passed / 3 skipped,
ruff check keel tests packagesclean,mypyclean (347 files).ruff format --checkon touched files reports only pre-existing drift already onmainunder this ruff (PEP 758 paren-dropping —mainitself would reformat 128 files); no new deviations introduced.