Skip to content

fix(strategy): fill entries at the next bar's open, as production's market orders do - #258

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/next-bar-open-fills
Aug 12, 2026
Merged

fix(strategy): fill entries at the next bar's open, as production's market orders do#258
eaitbrahim merged 1 commit into
mainfrom
fix/next-bar-open-fills

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #257. Option A of the fork raised on that issue, per your call: match the live path rather
than change money-moving code to accommodate a rule already measured as alpha-deficient.

The divergence

The simulator held a Setup until a later bar's range touched its entry, then filled at
that level. Production does not do that:

order_type="market", limit_price=None, expected_fill=intent.entry

Live never rests an order at Setup.entry and never waits for price to come to it. When
engine.evaluate emits a signal and the rails pass, the executor buys at market that cycle.

The old model therefore gave the backtest two things the live box does not have:

  1. Free optionality on the entry price — a setup only became a trade if the market offered the
    chosen level, so unfavourable entries were silently declined.
  2. Unbounded patience — any later bar touching the level filled there.

Both flatter results, and the bias runs opposite to #254's, which suppressed trades.

The change

Entries fill at candles[i+1].open plus slippage — the first price obtainable once the signal
exists, and the earliest fill involving no lookahead. Setup.entry becomes informational,
exactly as expected_fill is live; risk is measured from the achieved fill against the setup's
stop, never from the quoted entry.

This subsumes #254. Every pending now resolves on the very next bar, so the state that froze
the detector is unreachable rather than merely handled.

It also removes the wrinkle in #247's fee justification. "Touching entry is marketable,
therefore taker" held for a breakout entry above the market but not for a mean-reversion entry
below it, which touching would make a maker fill. With every entry a market order, taker is
unconditionally correct.

The consequence, named rather than hidden

A rule encoding a confirmation condition in its entry price no longer gets one.
pullback_continuation sets entry = signal_candle.high + buffer_ticks precisely to demand
follow-through, and a market fill takes trades it meant to decline.

That is not introduced here — it is what the live box already does, and modelling it faithfully
is the point. Option B (make the executor honour a stop/limit entry) is the alternative and stays
open on #257; it was declined because it changes live order routing to rescue a rule we have
already measured as alpha-deficient.

Tests

Golden regenerated (second time ever; #247 was the first)

profit_factor   1.269371 -> 1.269287
expectancy      692.0773 -> 691.9480
max_drawdown    14220.67 -> 14222.05
n_trades  13    win_rate 0.4615    (both UNCHANGED)

#247's reasoning for why n_trades held does not carry over — this is a fill-logic change,
so the count was free to move and simply didn't. It held because turtle_breakout enters at the
close and 24/7 crypto barely gaps, so both models pick nearly the same price on this corpus. Read
those deltas as "this corpus is insensitive to the fill model", not as "the fill model barely
matters" — the daily BTC baseline never exercises an offset entry. The docstring now says exactly
that, so the small numbers can't be misread later as evidence the change was cosmetic.

Pre-registered prediction for the re-run (in flight)

Recorded before results, so it can be wrong: n rises (every signal becomes a trade, including
the gap-away entries previously declined) and gross PF falls (the declined entries were
disproportionately the unfavourable ones). Largest effect expected on pullback_continuation,
whose entry is offset above the signal bar; smallest on turtle/rsi, which enter at the close.

🤖 Generated with Claude Code

…arket orders do

The simulator held a Setup until a later bar's range TOUCHED its entry, then filled AT that
level. Production does not do that. `execution/executor.py::_order_row` writes:

    order_type="market", limit_price=None, expected_fill=intent.entry

so live never rests an order at `Setup.entry` and never waits for price to come to it -- when
`engine.evaluate` emits a signal and the rails pass, the executor buys at market that cycle.

The old model therefore granted the backtest two things the live box does not have:

  1. Free optionality on the entry price. A setup only became a trade if the market offered the
     chosen level, so unfavourable entries were silently declined. Live pays whatever the market
     is doing, favourable or not.
  2. Unbounded patience. Any later bar touching the level filled there.

Both flatter results, and the bias runs OPPOSITE to #254's, which suppressed trades.

Entries now fill at `candles[i+1].open` plus slippage -- the first price obtainable once the
signal exists, and the earliest fill involving no lookahead. `Setup.entry` becomes informational,
exactly as `expected_fill` is live; risk is measured from the achieved fill against the setup's
stop, never from the quoted entry.

This subsumes #254: every pending now resolves on the very next bar, so the state that froze the
detector is unreachable rather than merely handled. It also removes the wrinkle in #247's fee
justification -- "touching entry is marketable, therefore taker" held for a breakout entry above
the market but not for a mean-reversion entry below it, which touching would make a MAKER fill.
With every entry a market order, taker is unconditionally the right rate.

Consequence named rather than hidden: a rule encoding a CONFIRMATION condition in its entry price
no longer gets one. `pullback_continuation` sets `entry = signal_candle.high + buffer` precisely
to demand follow-through, and a market fill takes trades it meant to decline. That is not
introduced here -- it is what the live box already does. Making the executor honour a stop/limit
entry is the alternative (#257 "Option B"); it changes money-moving code to accommodate a rule
already measured as alpha-deficient, so it is deliberately not taken.

Tests: entry-vs-stop intrabar resolution is deleted -- that ambiguity only arises for an entry
seeking a level, and is now unreachable. `_resolve_order` is still exercised for stop-vs-target.
#254's regression class is restated as `TestEntryFillsAtNextBarOpen`, asserting a setup whose
entry is never touched fills anyway, and that `detect_calls` is 1 rather than #256's 3 -- the
number that distinguishes the two fill models.

Golden regenerated. On the committed BTC daily corpus the move is tiny (profit_factor
1.269371 -> 1.269287, n_trades 13 unchanged) because `turtle_breakout` enters at the close and
24/7 crypto barely gaps. That is this corpus being insensitive, not the fill model being
unimportant -- it does not exercise an offset entry.

Closes #257.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit 433bb22 into main Aug 12, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the fix/next-bar-open-fills branch August 12, 2026 20:31
eaitbrahim added a commit that referenced this pull request Aug 13, 2026
…ine, and record why the defects were invisible (#261)

Both experiments re-run under #256 (pending setups no longer freeze the detector) and #258
(entries fill at the next bar's open, as production's market orders do). Same designs, same
combinations, same fee grid and slippage pin -- only the engine differs.

EVERY CONCLUSION SURVIVES EXCEPT ONE. ZEC-turtle no longer clears the maker line (1.034 -> 0.968),
so #252 section 6's three-probe narrative describes a survivor the faithful engine never produces.
The replacement is simpler and worse for the library: the viable quadrant is empty at every
reachable fee -- 0 of 90 in #252, 0 of 82 in #255 -- with nothing needing three gates to die.

#255 strengthens: the level shift across the trade floor widens from 1.1631 -> 0.8938 to
1.1251 -> 0.8396, gross-positive cells at the floor nearly halve (11/76 -> 6/82), and the 34x
UNI-USD monotonicity anomaly that exposed both defects is structurally gone (3 assets -> 0).

The two defects pushed in OPPOSITE directions -- #256 suppressed opportunity, #258 flattered
execution -- so correcting both moved everything one way rather than adding noise: trade counts
rose in 87 of 90, gross profit factors fell in 69 of 90.

Arm B's transfer is restated on a single engine: 0.5770 in-sample vs 0.5427 out-of-sample, a gap
of 0.034. #252's 0.6335 vs 0.6346 compared figures from one engine and was partly luck. The
conclusion is unchanged -- the sweep winner is not overfit, it is stably unprofitable.

Records the operational takeaway as section 5: NEITHER DEFECT WAS FOUND BY LOOKING FOR DEFECTS,
and neither was findable by the means we had. 2,712 tests passed throughout. A frozen backtest and
a highly selective strategy produce identical-looking output, so no summary ledger distinguishes
them. The fix is invariants the engine reports about itself -- a dead-tail warning, intent-vs-fill
divergence logging (#260), and cost anchored to output (#247, shipped, and the model for the rest)
-- not more unit tests, which only assert behaviour someone already imagined.

Annotates rather than rewrites, per the convention #247 set: the original numbers were real
outputs of the code as it stood. Both documents keep their figures and carry a banner pointing
here.

Two ledger rows; chain verifies clean at 85.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 13, 2026
… measurements in it

The scout skill lived only in ~/.claude/skills/, outside version control, with a single .bak as
its entire history. Most of it is not procedure -- it is recorded reasoning about measurements that
cost real compute to produce. An untracked file loses the argument along with the rule.

Tracked via `git add -f` because `.gitignore:34` ignores `.claude/`. That is the same exception the
two review-fix-merge-pr skills already use, so this follows existing precedent rather than
inventing a location.

Content added today, all additive -- all 13 original sections are intact, including the guardrails
(Do NOT, Rationalizations, the citation-fabrication warning, Output 2's rejection history):

- "Taker math controls the field": round-trip friction is ~2.5% of notional, levied on the SEARCH
  rather than the edge, and the viable intersection is vacant across all 24 assets. Liquidity and
  admissibility are entry conditions, not evidence.
- "What has already been measured": points at the three experiment documents, and separates asset
  expansion as a refuted PERFORMANCE fix from its still-valid uses (statistical power, the
  compliance pipeline, future rules) -- which is why the skill is not retired.
- Four new required citation fields: `cost_regime`, `sample_size`, `evaluation_window` and
  `not_already_covered`. Each is anchored to a specific way this project was misled -- a result
  measured at 5bp that does not transfer to 1.2%, rsi_meanrev's 1.1631 on n=38 collapsing to
  0.8396 at the floor, ZEC compressing 92.7% of lifetime PnL into one regime, and entry_lookback=55
  sitting inside an already-swept grid.
- Fill-model framing: entries fill at the next bar's open as market orders (#258), so a technique
  assuming a resting limit or stop entry will not execute as described.
- Four Rationalizations rows, including "this backtest looks clean, the numbers are all plausible"
  -- two defects produced plausible, internally consistent output for the life of the project while
  2,712 tests passed.

Two things deliberately NOT added, with the reasoning recorded so the next proposal to add them can
see why: a check on the pending-lifespan invariant (it raises rather than logs, so a violation ends
the run and is unobservable from a proposer), and a stationarity rejection threshold (ZEC under
pullback_continuation is the only pair in the study with no losing complete year, at 0.875 gross --
any such gate passes that and rejects the one thing that made money gross).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 13, 2026
… measurements in it (#263)

The scout skill lived only in ~/.claude/skills/, outside version control, with a single .bak as
its entire history. Most of it is not procedure -- it is recorded reasoning about measurements that
cost real compute to produce. An untracked file loses the argument along with the rule.

Tracked via `git add -f` because `.gitignore:34` ignores `.claude/`. That is the same exception the
two review-fix-merge-pr skills already use, so this follows existing precedent rather than
inventing a location.

Content added today, all additive -- all 13 original sections are intact, including the guardrails
(Do NOT, Rationalizations, the citation-fabrication warning, Output 2's rejection history):

- "Taker math controls the field": round-trip friction is ~2.5% of notional, levied on the SEARCH
  rather than the edge, and the viable intersection is vacant across all 24 assets. Liquidity and
  admissibility are entry conditions, not evidence.
- "What has already been measured": points at the three experiment documents, and separates asset
  expansion as a refuted PERFORMANCE fix from its still-valid uses (statistical power, the
  compliance pipeline, future rules) -- which is why the skill is not retired.
- Four new required citation fields: `cost_regime`, `sample_size`, `evaluation_window` and
  `not_already_covered`. Each is anchored to a specific way this project was misled -- a result
  measured at 5bp that does not transfer to 1.2%, rsi_meanrev's 1.1631 on n=38 collapsing to
  0.8396 at the floor, ZEC compressing 92.7% of lifetime PnL into one regime, and entry_lookback=55
  sitting inside an already-swept grid.
- Fill-model framing: entries fill at the next bar's open as market orders (#258), so a technique
  assuming a resting limit or stop entry will not execute as described.
- Four Rationalizations rows, including "this backtest looks clean, the numbers are all plausible"
  -- two defects produced plausible, internally consistent output for the life of the project while
  2,712 tests passed.

Two things deliberately NOT added, with the reasoning recorded so the next proposal to add them can
see why: a check on the pending-lifespan invariant (it raises rather than logs, so a violation ends
the run and is unobservable from a proposer), and a stationarity rejection threshold (ZEC under
pullback_continuation is the only pair in the study with no losing complete year, at 0.875 gross --
any such gate passes that and rejects the one thing that made money gross).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 24, 2026
#530)

* feat(db): record the stop a tranche was sized against, because the current 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

* docs(prd): what keel can learn from Jesse, which is not its strategies (#528, #529)

Compares keel's Rule ABC against the Jesse framework's Strategy API and its
published example strategies (jesse-ai/example-strategies -- jesse.trade is
Cloudflare-protected, and reading the source is the better record anyway: what
matters is what the framework lets a strategy SAY, not what its strategies claim
to earn).

The conclusion is deliberately narrow. keel is cost-bound, not signal-bound --
round-trip friction ~2.5%, no shipped rule family net-positive, turtle_breakout
negative on all 24 assets measured -- so porting DUAL_THRUST, KDJ, Bollinger and
the rest is the known dead end. A rule negative on 24 assets is not fixed by a
25th signal.

What Jesse's API can express that keel's cannot: conditional entry at a chosen
price, a per-bar update_position hook, pyramiding, partial exits, fill-event
hooks, per-strategy state, and a declared hyperparameter space. Four of those are
ALREADY keel's open issues (#333, #447, #502) -- an external framework
independently re-deriving the same gaps is corroboration of the roadmap, not an
addition to it, and the PRD explicitly declines to re-file them.

Two things are genuinely new, and are filed:

- #528: Jesse declares each strategy's parameter space. keel already consumes
  n_trials in research/deflate.py and reasons about the trials budget in source
  comments, but the number itself is hand-recorded. Declaring the space makes the
  input to keel's own overfitting correction derivable rather than remembered.
  Explicit non-goal: no optimiser -- running one against a cost-bound engine
  manufactures exactly what the deflated-Sharpe machinery exists to detect.

- #529: Jesse strategies name their entry price and assume the fill. keel priced
  that assumption when #258 corrected the fill model and pullback_continuation's
  gross PF fell 0.92 -> 0.77. Any foreign strategy ported naively inherits an
  optimism this project has already measured.

The document also records what keel does that Jesse does not -- rails, a detect()
with no account access, fill fidelity, trials discipline -- so the comparison
reads as a trade rather than a wishlist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6

---------

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.

The simulator fills at a price live never waits for: entries are market orders in production, resting orders in backtest

1 participant