Skip to content

feat(tuner): Entry/Exit axis — replay closed trades on their tape and search entry and exit fields - #738

Merged
guyverino merged 51 commits into
mainfrom
feat/tuner-ticks-axis
Sep 26, 2026
Merged

guyverino merged 51 commits into
mainfrom
feat/tuner-ticks-axis

Conversation

@guyverino

Copy link
Copy Markdown
Collaborator

What & why

A new Entry/Exit axis on Analytics → Strategy tuning. The existing axes (By filter, By coin, By time) tune the report's filter fields; none of them can answer "what if the entry or the exit had been placed differently". This axis replays every closed trade on its recorded tape of prints and re-runs the strategy's own entry and exit rules on it, so a change to an entry or exit field can be judged against real history before it reaches a live strategy.

  • Tape-replay model (moon-core/src/db/tuner/ticks/): MoonShot entry (corridor model or a shift of the fact, re-placing a retreating order only past the corridor's far edge, PriceDown chained off the placed price), MoonHook's own take (HookSellLevel × Depth), the sell line with its re-placements, stop-loss (ticker-driven and book-watching, UseStopLoss, trailing), the delta modifiers on sell and stop (Min(MaxModifier, Σ Pn·Dn) · SellModifier), shorts dividing their levels. Coin deltas are re-evaluated along the trade the way the core does. One field descriptor (params.rs) drives the grid, the search and the save.
  • Verdict against the report and the core's order archive: each trade is marked as reproduced or not, and the search learns only on the trades the model reproduces; a group under the reproduction share now warns instead of being locked out.
  • Search: exit search nested under every entry point, ranges taken from the live strategies or typed over in the grid, every run continues from V1 and lands over it; the result can be saved or copied onto strategies.
  • Tape: a background fetch job with parallel lanes and clusters, an optional startup autoload of the tapes the close-time capture missed, held tapes read in one batch; the core's ring is filed into the tiles; Gate futures trades walked by time and id.
  • UI (moon-ui-gpui/src/analytics/tuner/ticks/): a deal table (USDT profit, core column, status line, "tunable only"), the grid laid out by the Strategies window's sections, variants, the selected deal's pane with the model's sell path, model accuracy under the grid, a run estimate.
  • Storage tab: the trade-tape margin moves to seconds on a step list, a long-position threshold setting, a tape cleanup on the tab and optionally at startup, the autoload switch.
  • docs/PRODUCT_MAP.md lists the new axis.

Notable decisions

  • The model's inputs are the report, the core's order archive and the recorded tape only. The core's text log is never an input: it is not a stable interface.
  • A trade the model cannot reproduce stays in the table with its reason, but is excluded from the search; the accuracy line says how much of the history the search learns on.
  • The trade-tape margin is one setting shared by the trade window, the close-time capture, the tuner's fetch and the cleanup — nothing pads it on its own. This branch was rebased onto fix(trade-window): drop candles under the tick replay, 65 s margin #735, which made 65 s the default and a step; both changes are kept (steps from 30 s, 65 s default).

Known limitations

  • Only MoonShot has an entry model so far: for every other kind the entry is taken from the fact, the Entry group says so and is not searched. The Exit group is searched wherever the model reproduces at least one trade's exit.
  • The tape autoload spends the venues' public request budget, so it is off by default.
  • 17 clippy findings on lines this branch added were fixed in the last commit; the tree's pre-existing clippy debt (465 findings, none on this branch's lines) is out of scope.

Issues

None of the 47 open issues is touched by this change.

How to verify

cargo fmt --all -- --check
cargo build -p moon-ui-gpui --bin moonterminal --target x86_64-pc-windows-msvc --all-targets
cargo clippy --workspace --all-targets --target x86_64-pc-windows-msvc
cargo test --workspace --target x86_64-pc-windows-msvc

fmt --check exit 0; build of all targets clean; clippy: 0 findings on lines this branch adds (the tree's pre-existing 465 are unchanged, CI does not run clippy); tests: moon-core 2526 + moon-ui-gpui 2164 + 362 + 177 passed, 0 failed. FireTest not run (the maintainer's standing call: the bench is too noisy to judge by). The one chart change (the archived exit line of a closed trade ends at the report's sale) is for a visual check.

…y and take exit

New `db::tuner::ticks`: pure functions that replay the trade tape around a closed trade.
`mshot.rs` walks the MoonShot corridor (MShotPrice/PriceMin bounds with the MShotAdd* delta
modifiers, RaiseWait/ReplaceDelay, a replacement latency so a spike can reach the old level,
MinusSatoshi and the price grid, FastShotAlgo's 100 ms extreme as the reference); `exit.rs`
models the take with MShotSellAtLastPrice/SellPriceAdjust and falls back to the report's exit
until the moving line lands in phase 2; `verify.rs` reproduces the fact per group so a search
can be gated on the model's hit share; `params.rs` is the one descriptor of the axis' fields.
`strategy_values_at` reads a strategy's version as of the trade instead of its head.

Checked on the live tape: the modifier sign follows the FAQ example (a coin up 20 % puts the
order 1 % deeper), the adjustment is subtracted from the pre-spike price, and every probed
strategy runs FastShotAlgo. 4 of 4 entries with an archived line start reproduce within 0.05 %.
`ticks::read_deals` reads the tuner scope's closed trades through the unified report source
the other axes scan, so the "Fact" column and the replay describe the same trades; rows
without millisecond stamps are counted, not dropped, and each deal's strategy kind comes from
strategies.sqlite (`strategy_kinds`). The unified source now projects `reportuid`,
`buydatems` and `closedatems`.

The trade-replay worker answers a `TickQuery` from what it already holds — the in-memory
tiles hydrated from trades.sqlite — with no venue and no core asked; it is served right after
candle jobs so a table asking once per row is not paced by one window's paging. `hydrate`
reads the disk before taking the tile lock. The ignored real-data driver now runs through
both paths.
…e tape search

UI: a fourth tuning axis, "Entry/Exit". Under the strategy list, the deal table — one row
per closed trade with millisecond stamps, its market at the buy, why it closed, whether the
terminal holds its tape and whether the model reproduces the fact; "Fetch trades" asks the
venue for the rows it lacks, one at a time through the replay worker. On the right, the shared
matrix with the whole scope beside the replayable subset (captioned with the model's ✓
shares) and the parameter grid with the strategies' current values. Loads run in two stages
(database, then the worker's held tape per row); every Analytics cancel path knows the new
lanes; a per-row cancellation probe (`current_read_cancelled`) stops the replay loop early.
The per-axis column masks gain a slot that reads "unset" for a file saved before it.

Core: `ticks::line` walks the sell line under PriceDown / SellLevel / SellShot / StopLoss
(FAQ semantics, checked against the archived Exit lines: the moves land on the same seconds
and, from the factual entry, within tolerance); `verify` judges the exit group from the
factual entry and against the archive's moves, with a market-fill tolerance on stops;
`ticks::search` is coordinate descent with restarts over the descriptor grids, scoring a
point by replaying every covered deal, with train/holdout and a stop handle. A position no
rule closes inside the tape is no trade, not the report's exit.
The parameter grid gains the two variant columns as input boxes, a "vary" switch per group
gated by the model's reproduction share (a group under 80 % cannot be searched over — the
switch says why), and a "fix" tick per field that holds it out of the search. Every touched
variant is rescored over the rows whose tape is in memory (debounced), and the matrix draws it
beside the whole scope and the replayable subset, captioned with how many rows it describes.
The search row runs `ticks::search::suggest` into V1 with restarts, a minimum trade count and
a train share; the holdout's result rides the column's caption. Copy and Save write V1 through
the shared confirmation dialog, with the "closer MShotPrice is underestimated" line of the
spec on the way. Covered rows keep their tape up to a memory cap, enforced after every fetch;
a reload or a scope change stops a running search and rescores the columns; the axis' state
cancels its search when it goes.
…s in one batch

The fetch batch moves out of the view into a process-owned job (`ticks/fetch/job.rs`, one
thread behind a Condvar) that survives the Analytics window closing and axis reloads; the view
only hands it rows, listens for outcomes and reads `job::progress()` into the button caption.
The request carries `ReplayIntent::Model`: the run-up before the entry goes ahead of the tail,
no 30-second NativeWait, and the remembered-outcome ring is neither read nor written. A gate
refusal comes back as `TickStatus::RateLimited { retry_in_s }` from `ReplayGate::refused_for`
(no claim taken): the row and every row of the same venue wait that long while the other venues
keep going; a venue refusing mid-walk gets one retry after 30 s. The trade window shows the same
status with the seconds to reopen.

The coverage rule lives in moon-core alone: `db::tuner::ticks::required_spans` =
[entry - 30 s, exit + 30 s] intersected with the focus spans. The axis loads in three stages —
DB, rows at once without tape, then every held-tape read as ONE `held_tapes` batch (240 s
deadline) — because the single-threaded worker yields `Held` only between walks. The axis logs
under `moonterminal::analytics::tuner::ticks` (`TICKS_AXIS_TARGET`, info in the base filter):
`[x] ticks load` and `[x] ticks fetch N/M`.
…oad for the Entry/Exit axis

The replay worker becomes one coordinator thread (held-tape reads, core captures, native
follow-ups) plus one lane thread per host and intent: a chart window's request never queues
behind the tuner batch's minute-long walk on the same host, and the hosts — independent
budgets — walk at the same time. The gate's contract changes with it: `claim` only checks the
host's refusal history, `refuse` records a venue refusal (candle `Transient`, a walk stopped
on `Transient`) with the doubling backoff, `clear(host, asked_at)` erases only a refusal
recorded before the answered request was sent, and `pace_at` books `max(now, last + floor)`
under one lock so the per-host floor holds across lanes. Binance futures aggTrades pace at
650 ms (weight 20 of 2 400 per minute; 100 ms answered 429 after ~80 pages). A model's
run-up and tail (`MODEL_PAD_MS`, 30 s each) are trade tiles under the trade budget, no longer
a margin tile the normal budget cut short on a pumped coin; a model's window is built with
at least twice that pad.

The tuner's fetch job asks one request per exchange key at a time, several keys at once, and
each request is a cluster: the seed row plus every pending row of the same market whose
margined window overlaps, within one hour, walked once and replayed per row off the tiles.
A walk the worker stopped on its own budget short of the focus is continued while it gains
coverage (twelve times at most) instead of leaving the rows missing until the next press.
Rows of the open table and of a press are marked and go before the rest of their venue's
queue. `enqueue` merges rows into a running batch by id; a "+N more" button adds the table's
rows while another batch runs; the progress caption names every market in flight.

`[trade_replay] autoload_missing` in storage.toml (off by default, a file without it reads
off) fetches, once the cores are up, the tape of the last 30 days' closed trades the venues
still serve by the worker's own retention rule, retried while cores and catalogs come up;
Stop on the axis cancels it. The axis and the autoload take only trades the tuner can be run
on — service rows (funding, liquidations, joined sells, no strategy) and untunable ones
(container kinds, unresolved kinds, manual exits) are counted, not shown. Every deal row
carries a tape-status dot at its left edge; a period with only stamp-less rows says so
instead of drawing an empty table.

Measured on the live log: 12–14 rows/min with 24 fapi refusals per run before, 28 rows/min
with none after; OKX pumps (100 prints a page) close over continuations.
…120 min)

The "Prints around a trade" setting moves from whole minutes (floor 5,
stepper ±5/±15, ceiling 120) to seconds taken from a fixed step list —
10 s, 30 s, 1, 3, 5, 10, 15, 30, 60, 120 min — so a scalp can keep a
short tape while a long trade keeps its hours.

- `TradeReplayStoreCfg::margin_min` → `margin_s`; the old key is read
  through a raw on-disk shape and multiplied by 60, a hand-edited value
  snaps to the nearest step on load (the lower one on a tie).
- The live cell (`set_margin_s`) snaps the same way; `margin_ms()` and
  `model_margin_ms()` keep their units, so every reader — the trade
  window, the close-time capture, the tuner's axis (still ≥ 60 s) — is
  unchanged.
- The Storage tab's stepper walks the list (one step / three steps), the
  label reads seconds under a minute; the hint loses "0 — the position
  alone".
- One info line at init says what the file was read as.
…pe, one exit horizon

The table under the strategy list is the axis' sample, not a second
report: it keeps entry time, result %, profit in USDT, duration, what the
terminal holds around the trade, the sell reason and the two marks; the
prices and the market deltas are gone, and a double-click opens the trade
window on the row through the opener the Report already used
(`trade_window/open_record.rs`, keyed by the core's ReportUID — a
different counter from the replica's row id).

- `Deal::profit`: the row's money valued in USDT off a second unified
  source in the same snapshot (`tuner_source_usdt_on`), accepted only
  when the scope's money IS USDT (native or converted with coverage);
  otherwise the column shows a dash and the axis still serves. `spent`
  and `fact_pnl` stay in the scope's own unit, so the KPI is unchanged.
- `DealRow::held` — how far before the entry and past the exit the held
  coverage reaches — is the new "held" column, and the exit horizon of
  the sample is the shortest held trail among the replayable rows:
  every tape the variants and the search replay is cut there
  (`search::clip_to_horizon`), so no variant is judged on more tape than
  another; the KPI caption says "exit ≤ N past the close".
- The fetch button is only "Fetch trades" / "Stop"; the batch's progress
  moved into the caption beside it. The "+N more" button is gone: with
  the autoload on, the rows of the open table the venue can still serve
  go to the fetch as the tape stage folds. A row the venue cannot serve
  (no public route, older than its retention) is marked refused at load,
  in words, instead of reading as fetchable.
…in full

The tuner's Entry/Exit axis refused every Gate USDT-perpetual deal (75 rows overnight, then
"RateLimited 60 s → Failed → Refused") while the venue answered HTTP 200 in 300 ms. Two row
shapes the futures trades parser did not read:

- `create_time`/`create_time_ms` are fractional SECONDS as JSON numbers (`1789726954.306`;
  the vendor types the field `float`, "millisecond precision to 3 decimal places"), where
  spot sends a millisecond string. A `cell_i64` read rejected every row, the page became
  `Transient`, and the gate backed the host off for minutes on our own parse failure.
- Small contracts (UB_USDT, M_USDT) print `size: 0` rows between real fills, with their own
  ids and a price. A zero quantity was "unparseable" too — the same refusal.

Every trade-page parser now splits zero-quantity rows off before its hole check
(`split_no_fill`): nothing traded, no tick, not a malformed row. The page stands and the
cursor advances by the venue's own row count. Gate's `to` is sent one second past the slice
end — a second-valued `to` excludes the fractional rest of that second (probed live on both
routes), so a truncated `to` left every slice's last second unasked while the walk marked it
covered. The walk now logs the venue's diagnostic before abandoning on `Transient`; the log
used to say only "Transient".

Recorded fixtures pin both Gate row shapes and the zero-size page; an `#[ignore]` probe asks
the live endpoint for both markets.
…nutes

Two defaults on the developer's call (2026-09-21):

- `[trade_replay] margin_s` gains a 5 s step and defaults to it (15 minutes before). The
  step list now runs 5 s … 120 min; a saved file keeps its own value, a hand-edited 7 snaps
  to 5. The chart's windows follow the setting; the tuner's model window and the close-time
  capture both take `model_margin_ms` — at least the model's own run-up and tail (60 s) — so
  the short default never leaves a captured tile too narrow for the tuner, which would have
  sent every freshly closed trade back to the venue for the seconds the ring held for free.
- `LONG_POSITION_MS` is 5 minutes (an hour before): past it a window walks ticks only around
  the entry and the exit, and the tuner's fetch clusters stop growing their hull there. The
  model runs such a position on the two-end tape as it is — an exit that really happened in
  the unwalked middle is a miss in the replay; the deal table's "held" column shows how far
  the tape reaches.

The Storage-tab hint and the tests that pinned the old values follow.
…eader

The tuner's Entry/Exit axis asks the worker with `ReplayIntent::Model` and then reads the
tile store, not the answer. Three paths answered such a request straight from a core's
retained trade ring — the candle stage's core-first, the tick stage's own read, the mid-walk
upgrade — and none of them files a tile: a row whose ring bracketed the position came back
`Served` with empty tiles and stayed missing on every fetch (19 of 925 rows on 2026-09-21),
and a mid-walk replacement threw the walk's fetched pages away. The close-time capture that
would have filed the ring never ran for a trade that closed while the terminal was down —
exactly the autoload's rows.

`ReplayIntent::files_core` (Model) turns the ring into tiles instead: after the disk hydrate,
and only when the store does not hold the focus whole, what the ring holds inside each focus
span is filed as `TileSource::Core` — the capture's own copy, through the same
`file_core_span` — so the walk asks the venue only for what the ring does not hold, and the
held query finds it. The three answer-from-the-ring paths are skipped for that intent; a
chart keeps them. The log tells the two apart: `capture` at close time, `backfill` here.
…long-position threshold as a setting

`trades.sqlite` knew markets and stretches of time, not trades, and the only rule that ever
removed anything was the file's byte ceiling. The Storage tab now has one "Clean up" button:
the report's tunable rows (the tuner's own filter — strategy trades the strategy closed itself)
claim their windows' focus at the model margin, per market of the file, and everything outside
the union goes — what a wider margin left behind, manual sells, funding, liquidations and prints
with no trade in the report. Rows are resolved through the live catalog like a trade window,
or by the coin's spellings against the file's own markets when the core is offline. The count
under the button is a dry run of the same pass; the apply pass rewrites the file in one
transaction, VACUUMs, and drops the worker's in-memory tiles so a held-data query cannot keep
answering from prints the disk no longer has.

`[trade_replay] cleanup_at_startup` runs the same cleanup once per launch, 20 s after the first
coordination tick, and the tape autoload's first pass waits for it: cut first, then fetch what
the tuner's rows still lack.

The five-minute long-position threshold moves from a constant to `[trade_replay]
long_position_min` (1..=120, stepper on the tab). It is captured on `ReplayWindow` at build,
like the margin, and carried by the outcome key and the close-time capture, so one request is
clustered, walked, judged and drawn as the same shape whatever the tab did meanwhile.

The trades section's copy is cut down to what the controls need.
…fset

The Entry/Exit tuner replayed every Gate USDT-perpetual deal over a broken tape: the
`GateFuturesTrades` route paged by the endpoint's `offset`, which the venue does not honour
under back-to-back requests — five requests 100 rows apart came back as rows 0, 100, 100,
300, 300, then skipped a page (GSTOCKBSC_USDT, 2026-09-21) — so the stored spans held prints
repeated up to nine times and holes of half a minute inside a stretch filed as covered.

- `TradeCursor::Before { boundary_ms, below_id }`: the next page ends at the second of the
  oldest row seen, and the rows already taken are told apart by trade id; `limit` 1000 (the
  documented maximum; a three-minute window on a pumping contract is one page).
- `TradeCursor::Within { second_s, offset, below_id, low_id }`: a second holding more prints
  than a page (AKE_USDT 1789811988, 1 451 prints) cannot be entered by a whole-second `to`; it
  is drained by `offset` pinned to that one second — the one place `offset` is used, where it
  answered the same contiguous ids on every probe — then the walk resumes by time. The
  earlier `Transient` there backed the whole host off for 30 s ("waiting for the venue").
- The futures route is paced at 350 ms a page; `walked_part` treats both cursors as backward.
- `trade_cache`: a one-off repair at open (`repairs` table) drops every venue span of a Gate
  futures platform filed by the offset walk, to be fetched again whole.
- `worker/tests.rs`: an `#[ignore]` probe, `MOON_TICKS_PROBE=<route>,<market>,<from_ms>,<to_ms>`,
  walks one slice against the live venue with the real pager (allowed in the diagnostics
  contract as a by-hand test switch).
…e line's start from the archive

The Entry/Exit axis marked most exits ✗ for reasons that were not the tape:

- The verdict sold the modelled line on the first print at its level and stopped walking, so
  a fill the core did not get (31 contracts printed at the level against a sell of 18 000 —
  COOL, 2026-09-21) ended as "take against Auto Price Down: not comparable". Which print fills
  a resting sell is the queue at the level (the spec's §7), which the tape does not carry; a
  print at the level sold the core's line on ARX and left it standing on COOL the same day.
  `verify` now walks the line HELD through the close (`line::walk_held`) and judges where it
  STOOD when the core sold — the level within the model's own latency of the close, the take
  when no move had reached the exchange — plus every archived move re-placed. A stop stays
  judged by its own print, a late one too when the fact was a stop.
- Kinds without a take rule of their own (MoonHook, Spread, PumpsDetection) start their line
  at the archived Exit line's first point (`Deal::archived_take`, verdict only — a variant
  computes `SellPrice` for every kind, so varying it moves every column): FLOCK's HookN0 take
  sat at -2.2 % where `SellPrice` said -1.0 %, and every PriceDown step after it was off.
- `MShotSellAtLastPrice` on a short adjusts the ask UP toward the entry: the archived take is
  divided by `(1 + adjust)`, not `(1 − adjust)`; every short's line was 2·adjust off.
- The entry model takes the whole archived Entry line: the level at the tape's first print is
  the last archived one before it, and moves archived inside the blind window (the first
  `max(MShotRaiseWait, MShotReplaceDelay)` of the tape) are applied as archived — a wait the
  core began before the tape expires at a moment the tape cannot give. The entry is held to
  the corridor's width (`MShotPrice − MShotPriceMin`), not to a price step: an order chasing a
  falling price is re-placed off whichever print left the corridor, a second apart between the
  core and the model.
- Levels go to the exchange on the price grid: the entry snapped away from the price, the sell
  line rounded to the nearest step, the rule chain unrounded (ARX: the floor computed to
  0.196445, the core stood at 0.1964, the tape's high was 0.1964). Take vs Line by whether a
  move reached the exchange, not by price equality.
- A fill better than the level by up to 0.3 % is that level's fill, when the archive
  corroborates every move; the archive's fill point at the sale is not a move to match.
- `Deal::core_name` for the table; the by-hand `real_data` probe replays every tunable kind,
  takes `MOON_TICKS_COIN` to narrow to one coin, and prints the kind.

Measured on one snapshot of the live base, 1 228 deals with tape: exit ✓ 413/953 → 621/1159,
entry ✓ 78/467 → 432/539; Binance MoonShot ≈ 85 %, the rest mostly a stop's market fill
against the print. MoonHook on Gate stays at ~100/363: its PriceDown chain does not follow
the model's — next.
…the deal table

- A "core" column after the coin (`Deal::core_name`); the entry-time column and the tape
  dot before the coin are gone — the dot said what the tape column says, and the two rows
  set the shrink rule differently, so the headings drifted off their cells on a narrow
  table. Heading and row cells now come from one box builder (`deal_cell` / `coin_cell`).
- A status line under the table: how many rows the tuner counts, the coverage caption, and a
  "tunable only" checkbox (on by default) that shows only rows with their tape whose fact the
  model reproduced (`DealRow::tunable`: covered, no group ✗, at least one ✓); an empty table
  under the switch says how many rows it hides. The caption beside the fetch button carries
  only the batch's progress.
- The row order cache keys on the switch; the archived entry line rides the row as
  `entry_line`, and the loader hands the model the archived take and the trade's side.
…p, and a sample the verdict stops filtering

Measured on this machine's replica (1 714 trades with tape, one `real_data` run before and
after each step): the exit reproduces the fact on 909 of 1 622 against 877 before, all of the
gain on MoonHook takes — 40 of 145 `Sell Price` trades against 8.

- **A MoonHook has no `SellPrice` field at all.** Its take is `HookSellLevel` per cent of the
  trade's own detect depth (core FAQ: "заменяет SellPrice… в процентах от глубины детекта"),
  and the model read `SellPrice` anyway, got the schema default and missed 117 of 118 such
  trades. `hook.rs` parses the depth out of the report row's `comment`, where the core also
  states the level it placed; `HookSellLevel · Depth / 100` reproduces that number on 90 of 118
  within 0.01 pp, with the buy price as the base. The archive cannot stand in: the core files a
  line only when it was re-placed, so 0 of 155 take-closed trades carry one against 1 483 of
  1 484 for `Auto Price Down`.
- **The Delta Modifiers move the levels of every kind** — `Min(MaxModifier, Σ Pn·Dn)` times
  `SellModifier` on the sell, times `StopLossModifier` on the stop. Neither was modelled;
  `SellModifier` is set on 490 live strategies, `StopLossModifier` on 415 of 1 869. The stop
  formula is the core's own log line, `StopLoss adjusted [-2.00% - (0.20*1.86=0.37%) => -2.37%]`,
  and the arithmetic of all 136 such lines read off live cores reproduces exactly. An
  adjustment that pulls the stop through the entry leaves no stop rather than one a hair from
  it; no replayed trade reaches that branch.
- **`param_keys` fetched only the grid's own fields**, so everything the builders read but the
  grid does not show — `SellModifier`, the `Add*` family, `HookSellFixed`, and
  `SellShotPriceDown`/`SellShotPriceDownDelay` since the axis was written — read as the model's
  fallback. `MODEL_ONLY_KEYS` closes it, and the descriptor test now derives the truth from
  `params.rs`'s own source instead of an allowlist that cannot see what is missing from both.
- **The model's verdict is a column, never a filter.** The row flag that hid two thirds of the
  table is gone: the switch under it now filters on tape coverage — the sample the variants and
  the search actually replay — and is off by default, because the rows without their tape are
  what the fetch button is for. A sample narrowed to what the model already fits would be
  fitted on itself.
- **An unknown take answers nothing.** A MoonHook with no depth, no level or `HookSellFixed`
  (a branch no live strategy sets, so it is not modelled) leaves the whole line under it
  invented, and the exit group says `None` rather than ✗. Read wider than that — every kind
  without an archived line — it silenced five legitimate verdicts, four of them hits.
- **A sale that moved more coins than its entry bought** is excluded like a manual one: on spot
  a position under the minimum lot is topped up from the wallet balance, and the price is then
  an average over coins the trade never bought. One row of 606 767 here, so the exclusion costs
  nothing and matters on a core that does it often.
- The grids are sized to practice, measured over 1 869 live strategies: `SellPrice` to 11 %
  (300 sat outside), `PriceDownPercent` from 1 % (121), `StopLoss` to −15 % (29).

Stops are NOT fixed by this: 70 of 311 still reproduce. The modifier was not the cause — of 245
misses, 175 have a level off by more than the 0.3 % market-order tolerance, which is the stop
LADDER (`PriceToSwitchStop3` on 1 865 strategies, `StopLoss3` on 1 656) that the model does not
have; the rest match on price but not on the archived line's moves. Trailing is not it either:
`UseTrailing` is on for 105 of 1 869.
…ill point in the verdict

Measured on this machine's replica (1 735 trades with tape, one `real_data` run before and
after, compared on the same report uids): the exit reproduces the fact on 1 065 of 1 701
against 921 of 1 660 before.

- **Two stops, told apart by the core's own reason.** `StopLoss Market Sell` is
  `FastStopLoss=YES`, fired on a print; `StopLoss AutoActivated on price drop: BID = …
  StopLoss fixed: X` is the default NO, which watches the book's BID averaged over
  `StopLossEMA` samples (core FAQ), and 173 of 184 such stops come from strategies that omit
  the field. The model fired every stop on the first print through the level — a median 3.9 s
  ahead of the core's activation (read off the archived exit line's jump past the stop) with
  `StopLossEMA` at 3, 0.6 s without. A non-fast stop now samples a BID proxy — the last taker
  print on the stop's side — every `STOP_SAMPLE_MS` (2 s, a calibration on 199 activations,
  not a core constant) and averages it over `StopLossEMA` samples; both medians land within
  0.4 s.
- **A book stop is judged by what it decided, not by its fill.** Its sale is a panic sell
  walked through a book the tape does not carry, and 0 of 173 passed on the sale price. The
  verdict now holds the model's stop level against the core's printed `X` and the firing
  moment against the activation; a reason the column cut inside the level (28 of 206) is
  judged by the moment and the line. A stop the core fired and the model, holding one, never
  did is a miss, not an unanswered question.
- **`UseStopLoss=NO` arms no stop.** The value stays in the dump of 98 strategies whose stop
  is off, and the model placed it anyway.
- **The archived exit line ends on the fill, not on a move.** The core files it at the sale
  price, no worse than the level before it, a median 250 ms before `closedatems` — so the old
  close-stamp window missed it and 145 `Auto Price Down` trades failed on that one point with
  every move before it reproduced. A level placed through the market and taken within a
  second is accepted with whatever improvement the book gave; a level that rested keeps the
  0.3 % bound. `Auto Price Down` 817 → 977 reproduced.

The stop ladder (`UseSecondStop`, `UseStopLoss3`) is not the cause of the stop misses: its
fields sit in the dump with the flag off, the flag is on for 78 and 4 of 1 422 strategies,
and only 2 trades in the whole history ran with it — neither closed by a stop.
…the archive's clock, per-core step lag and PumpMove

Measured with the `real_data` probe on this machine's replica, one run before and after on the
same 1 613 trades with their own venue's tape: the exit reproduces the fact on 1 305 of 1 588,
against 1 077 of 1 585. By kind: MoonHook 54 → 78 %, MoonShot 80 → 86 %, Spread 84 → 89 %,
PumpsDetection 8 → 54 %; "Auto Price Down" 908 → 1 108 of 1 152. The entry is untouched
(591 of 735).

The probe first:

- **The exit share was not the one the table shows.** A trade without an archived Entry line
  had its exit judged with no archive at all, while the axis (`replay_row_with`) always passes
  both lines. The previous "1 065 of 1 701" was 1 157 of 1 701 on the table's own terms.
- **A coin's tape was every venue's tape at once.** The probe read a coin under each exchange
  that stores it (AKE sat under five) and replayed the mixture; a deal of a venue with no tape of
  its own (BB1 is Bybit) was judged on Binance's prints. Each core's venue now comes from the
  app log's `identity` line; 122 trades judged on another venue's tape left the sample.

The model:

- **A PriceDown step chains off the ORDER's price once the move reached the book**, and off the
  computed value while rounding kept the order where it was (`line::advance`). On 1 421 archived
  PriceDown lines this reproduces every level of 997, against 647 for the exact chain.
- **Each core's replace lag spaces its steps** (`calibrate.rs`, `Deal::step_lag_ms`): the core
  times the next step from the previous one going through — 47 ms on GateF, 31 on the BinF
  cores, about 0 on F1…F6 — and only after a step that moved the order. The axis calibrates it
  per core off the archived lines it loaded and holds it for the process (`ticks/lags.rs`), so
  the load, the fetch job and the startup autoload replay on the same clock.
- **PumpsDetection's PumpMove**: once, `PumpMoveTimer` after the take, the sell goes to
  `PumpMovePersent` of the peak-to-buy distance short of the pump's peak; the peak is read from
  10 s before the take. Reproduces the moved level to the tick on 31 of 32 archived lines.

The verdict:

- **The level is judged at the archive's filed fill**, not at `closedatems`, which the report
  books up to seconds later.
- **On the archive's clock** when the model re-placed at every archived move: the level at the
  fill is the model's own point for the core's last move, and only a step the model took with
  no archived move to match, more than the point tolerance before the fill, still counts. Points
  were already matched within a second; the level at the fill was read to the millisecond.
- **The fact's sell timers start at the take** (`fact_sell_start`): the core starts the sell
  when it books the buy, up to 32 s after the report's first-fill stamp.

The table: every fold of a replay answer into a stored row now goes through
`DealRow::take_replay`. The fetch job's fold kept only the price step and dropped the held
coverage, so the variants replayed a take read off the tape instead of the archive's pre-spike
ask, and a fetched row's zero trail clipped every variant tape at its close.
…ts on the fact, take after the latency

Measured with the `real_data` probe on this machine's replica, the same 1 613 trades with their
own venue's tape as the previous commit: the entry reproduces the fact on 591 of 735 (unchanged),
the exit on 1 308 of 1 536 against 1 305 of 1 588, and 1 186 trades are fit for the search. On
the fresh replica (1 737 trades) the exit is 1 406 of 1 656 and 1 276 are fit; a trade's own
settings replayed as a variant land at 0.842 % against the fact's 0.812 % on the fit trades,
where they ran 0.31 pp above it before.

The search sample:

- **Only a trade the model reproduces is searched** (`record::fit_for_search`): the entry right
  or taken from the fact, the exit judged and right. A trade the model cannot reproduce under
  its own parameters answers nothing for a variant. It stays in the table with its verdict; the
  variants, the search and the "Fact · reproduced" column run on the fit ones, and the table's
  switch shows exactly that set.
- **A sell rule the model does not have leaves the exit unjudged** (`exit::UnmodelledRule`):
  the trailing stop and the stop ladder, read by their switches.
- **The exit is judged only when the variant knows its take** (`ExitModel::take_known`, now per
  variant and for stops too): a hook needs its depth and level, a Spread the archived take, a
  MoonShot lifted to the last price the archived take to read the ask back from.

The model:

- **A variant that keeps the trade's own entry fills at the report's price** (`Deal::own_entry`),
  and one that also keeps its stop settings stops where and when the core did, and not before
  (`record::StopAnchor`): the moment from the archive's jump past the level, else the close. The
  verdict replays without either (`record::unanchored`), since it tests the model.
- **The take reaches the book `latency_ms` after it is placed** (`line.rs`, `take_live_at`). The
  tail of a spike printed in the first milliseconds after the buy used to fill it: 30 of 88
  stopped MoonShot trades won in their own settings' replay.
- **A Spread's take is not `SellPrice`**: the field places it on 5 of 157 archived takes. The
  level comes off the detect and the report keeps only the spread's width, so the take is the
  archived one or none (`exit::take_is_recorded`), and `SellPrice` leaves the Spread grid.

The verdict:

- **Every stop is judged by what it decided** (`verify_stop`): the level against the stored
  reason's `StopLoss fixed`, the moment against the archive's jump past it. A market stop
  (`StopLoss Market Sell` is `UseMarketOrder`, 149 of 149) has no level on record and is judged
  by its moment and line, never by the sweep's price: 18 of them fired on time and failed on the
  sale alone.

Nothing is read from the core's log files: the report, the order archive and the tape are the
only inputs.
… retreating MoonShot only past the corridor's far edge

Cores file three new report columns since 2026-09-21: `buysetdatems`, when the entry order was
created, and `buycorridordown` / `buycorridorup`, the entry corridor the core last saved.
Measured with the `real_data` probe after the tape was fetched from the creation: 1 759 trades
with tape, the entry reproduces the fact on 660 of 804 and 1 307 are fit for the search. On the
1 721 trades common with the run before this change, the entry of a trade stamped with its
creation goes from 204 to 220 of 254, an unstamped one from 438 to 435 of 538, and the exit
stays at 1 402 of 1 650.

- **The model's window opens at the order's creation** (`ticks::model_window`) when the order
  waited at most `ORDER_WAIT_CAP_MS` (10 min) and creation to close fits one span under the
  long-position threshold; otherwise at the buy, as before. For every auto kind, not MoonShot
  only: the tape is fetched and kept now, a model may come later. The tape cleanup claims by
  the same rule (`model_window_at`), so a fetched lead is not trimmed back.
- **A stamped MoonShot starts at the level the record proves** (`record::entry_placement`): the
  archived entry line's first point when it starts at the creation (165 of 165 do), or the buy
  price when the archive answered with lines but none for the entry, since the core files that
  line only once the order moved. An empty or missing answer proves nothing and keeps the old
  start. From there the corridor runs on the tape with no hints from the archive.
- **A variant is placed at the creation by its own bound** (`mshot::placement_at_creation`):
  the reference is read back from the fact's level at the middle of the price step, since that
  level was rounded, and the variant's `MShotPrice` places from it. Before, a variant started on
  the fact's level, and a different `MShotPrice` changed next to nothing.
- **A retreating order is re-placed only past `2·far − near`**: the saved corridor is a band of
  order prices symmetric around the placement, `R·(1 − near)` … `R·(1 − (2·far − near))`. The
  model's band matches it to 0.05 % on 151 of 273 trades; the probe checks it on every run.
…t the venue's retention served from the held tape

A trade older than its venue's trade-history retention showed candles alone in its trade window
however much of its tape the terminal held: the tick stage, the one that reads the tile store
and its disk before the venue, was refused on the retention before it was queued. On this
machine's replica a Binance futures trade of 2026-09-18 (retention 48 h) held its tape from
60 s before the entry to 60 s past the exit in trades.sqlite; its window now serves 330 prints
from it.

- **A window past the retention runs its tick stage** (`worker::tick_stage_for`, now
  infallible): the stage serves what the tiles hold for the focus, like a venue with no route,
  and walks the venue only for what is still inside the retention. `OutOfRetention` is printed
  only when the tiles hold nothing (`serve_ticks`, `serve_held`).
- **A long position owes the tuner its pads, not the margin** (`ticks::required_spans`): the
  requirement is clipped at the run-up and tail around each end. Owed in full, a wider margin
  turned trades the model already had into missing ones, and one past the retention could never
  be covered again. The `real_data` probe at a 3 min margin: 1 780 trades with tape, against
  1 657 before.

The margin setting ("Trades around a trade") now sizes every consumer alike, the developer's
call:

- **Steps start at 30 s, the tuner's run-up and tail (`MODEL_PAD_MS`), and 30 s is the
  default.** The chart's window, the close-time capture, the tuner's fetch and the cleanup take
  the setting as it is; the hidden one-minute floor (`model_margin_ms`) is removed. A saved 5 s
  or 10 s loads as 30 s. Measured on the probe with the tape clipped around each trade, 5 s /
  30 s / as held: exit reproduced 1 398 / 1 406 / 1 406 of 1 646, fit for the search 1 292 /
  1 295 / 1 276 — 30 s is what the model's check needs.
- **A long position gets the whole margin on both sides of each end**
  (`ReplayWindow::focus_spans`), not half of it: the stretch past the exit is the search's exit
  horizon, and a long trade must get the same one as a short trade.

The `real_data` probe dumps the entry order's creation, placement, saved corridor and MoonShot
bounds, and takes `MOON_TICKS_CLIP_MS` to replay the verdict on a shorter tape.
…does, and read every Add* modifier

- deltas.rs: DeltaTrack re-evaluates d1m..d24h as max/min ranges over the
  core's windows (5-second trade buckets; five-minute candle windows reaching
  one candle past their name, d3h/d24h never below d1h), refreshed on
  5-second boundaries over what printed before them, from the held tape plus
  klines.sqlite bars. Anchored to the report's snapshot at its stamp (the buy
  for MoonShot, the order's creation otherwise); a trade the anchor cannot
  reach keeps the snapshot.
- The MoonShot corridor re-reads its bounds per step; the sell modifier sum
  and the stop adjustment read the deltas at the fill.
- Add* family: AddMarket24Delta, AddPump1h and AddDump1h are read; the market
  deltas count by magnitude (FAQ), MShotAddMarketDelta keeps its sign.
- The loader and the fetch job build the track before prepare_deal, and
  take_replay carries it to the variants.
- real_data bench: snapshot/live A/B (MOON_TICKS_SNAPSHOT_DELTAS), a delta
  fidelity report, and a latency override (MOON_TICKS_LATENCY_MS).
…ow close each comes

- deltas/ (was deltas.rs): field, series, coin, btc and quality modules. A
  window is read over whatever history exists — no continuity required — and
  the share of it the history covered is kept per field.
- New live fields: d5s (the move over the last 5-second bucket), Pump1h and
  Dump1h (from the price an hour ago to the hour's high and low), and BTC's
  1m/5m ranges and signed 1h deviation from its exponential average, read off
  BTC's market on the same exchange (resolved per core by FetchResolver).
  MShotAdd5sDelta is read.
- A report zero counts as unfilled for the coin's ranges and every BTC field.
- The track keeps, per field, the window coverage and the evaluation's
  distance from the report at the stamp before the anchor; the Entry/Exit
  status line shows "deltas: live on N of M" with a per-field tooltip, and
  names the deltas it does not re-evaluate (mark price, price bug, market).
- The real_data bench prints the same summary.
…ke, re-place one order at a time

The core developer's answers of 2026-09-23, checked against the archive and the tape:

- the non-fast stop reads the REST ticker's bid every ~2.15 s; a long at StopLossEMA
  3/5/10 averages it as (avg*(N-1)+bid)/N, warm from before the fill; any other N and
  every short watch the bare price; at 0 the core's 250 ms price series fires it too;
  the verdict judges its moment within one ticker gap (2.3 s)
- a short MoonShot take is fill/(1+SellPrice/100), the ask branch ask/(1-adjust/100)
- the corridor is measured off the last print; a re-placed order goes off the last
  100 ms low for every RaiseWait; the run-away edge is far+min(near, far-near); no
  re-place while the last one is in flight
- the bench dumps the stop, measures the order's archived path and reads each core's
  replace round trip

Bench (1789 trades with tape): exit 1457 -> 1502 of 1712, fit 1326 -> 1370,
StopLoss AutoActivated misses 101 -> 59, entry 673 -> 672 of 823.
…s hour average

The core developer's answers of 2026-09-23, kept where the bench showed them help:

- "d3h" and "d24h" read closed candles only, counted back from the last five-minute
  close on the clock grid; d1h still floors them
- Pump1h / Dump1h run off the open of the oldest candle of the hour's window
- BTC's hour average is re-seeded on every five-minute close as the mean OHLC4 of the
  hour's closed candles, stepped 0.01 per 30 s toward the price since; an hour without
  a closed candle has no average
- the stamp check keeps its error signed; the bench prints d1m/d5m errors per core

Error at the report's stamp over 1122 tracks: d3h 0.034 -> 0.022 pp, d24h 0.324 -> 0.171,
pump 0.313 -> 0.302, dump 0.268 -> 0.250, btc1h 0.084 -> 0.054; verdicts unchanged.

Measured and left out: d15m/d1h on the grid (closer at the stamp, worse after it), a
growing d5m window, one series point per tick, a mark price read as an average of the
price, a later MoonShot stamp.
…or as a shift of the fact

Two separate entry models, picked by a setting the axis holds (no control yet, the corridor
model by default):

- Model: the corridor from the order's creation, as before
- Shift: the fact's order where it stood at the spike - the archive's last move before the
  buy - moved by the variant's far bound at the deltas of that moment, filled by the first
  print that reaches it through 2 s past the buy

The trade's own strategy takes the fact's fill whichever model replays it. The search and the
variant columns carry the setting (`SearchParams::entry_method`, `variant_tally`).

Bench: on 273 pairs of cores filled on the same spike with different MShotPrice, predicting
the second's fill off the first's tape, the model is off a median 0.156 % (99 within 0.1 %,
264 filled), the shift 0.078 % (143 within 0.1 %, 250 filled).
…s, and stop re-judging it

The right panel of the Entry/Exit axis is laid out as By filter's:

- KPI: the reproduced trades as the baseline, then V1 and V2; the whole-scope Fact column and
  its query are gone (the axis works on the reproduced trades only)
- grid: a participation tick per field and one in the header, a click on a name picks the
  field for "Search", the strategy value chip sends it to V1, per-cell clear, → / ← copy the
  column
- search row: restarts inline, the rest behind a gear (min trades, passes per restart, group
  reproduction gate, a variant's entry method, train share, seed and its pin), "Search" for the
  picked field and "Search all"; fields the entry method does not read are greyed and skipped

Every constant the replay model used is now a `ModelSettings` field (re-place latency and
window, shift window, pre-spike lookback, ticker period, series tick, step floor, pump lag and
peak window, the verdict tolerances), carried in the entry and exit parameters, defaults equal
to the old constants (bench unchanged: entry 670/823, exit 1502/1712, 1370 fit). A "Model"
popover over the deal table edits them process-wide - the fetch job replays with them too -
and a commit re-judges the table. The axis settings persist in the window layout.

A reload - every few seconds while reports land - now carries the rows the last load judged
under the settings in force and replays only the rest, so the table and the KPI stay on screen;
the tape stage has its own generation, so a superseded stage cannot fold over a newer one. The
fetch job's answers of one listener hop land in one recount. The startup autoload skips trades
whose tape trades.sqlite already holds, read off the span bounds one query per market, instead
of serving every one of them again on each launch.
…he variants' trades

Trade window:

- report rows carry `buysetdatems` and the saved entry corridor (`buycorridordown/up`); where
  the core archived no own entry line (it files one only once the order moved), the entry line
  is drawn from the report, placement to fill, in the window and in the live chart's "Moonbot
  lines" style, and the entry arrow is no longer drawn over it
- a "MoonShot zone" switch (off by default) shades the saved corridor from the placement to the
  fill, drawn through a new frozen overlay beside the archived store in either trade style

Entry/Exit axis:

- a pane under the deal table, folded by default behind a rail, hosts the same trade view in an
  embedded host (no window chrome, keys or geometry; figures rail hidden); a click on a row
  shows the deal there, a double-click still opens its window
- the pane draws what V1 (dashed) and V2 (dotted) would have made of the deal: the entry path
  the model walked, the fill, the exit and, under the zone switch, the modelled corridor
  placement by placement (`search::variant_picture`, `MshotEntry::corridor`)
- the deal table gains a strategy kind column and a "plan V1" column, each deal's share of the
  V1 column (`variant_tally_by_deal`), per cent in percent mode, a dash for a deal V1 makes no
  trade of; wider money columns and heading tooltips

The Analytics window paints no root fill any more (NoFill, the shell as the clear colour kept on
the palette): the chart draws under the GPUI scene and the opaque root hid it.
The core's archived exit trace is the sell ORDER's movement; the report's sell price is where the
position closed. A stop or panic sale by market leaves no price on the order, so the trace stops at
the take it last stood at and the chart drew the exit line and its end there — a sale "in the sky"
(FOLKS BinF3: take 2.899 → 2.879, sold by StopLoss Market at 2.5808).

The trade's first own exit line now stands at the report's sell price, and a trace that ends
elsewhere (beyond the store's float-jitter bound) is carried to it at the close as one more
SetPointTrade move: held at the last price to the close, then down to the sale. A trace off the
anchor-and-triples layout keeps its points. Inherited exits and further own ones are untouched.
Applies to the trade window and to closed trades in the "Moonbot lines" style.
…and the tapes

Entry/Exit axis:

- a variant, its plan column, the trade pane's picture and the search lay their values over each
  deal's OWN strategy as it stands now (`PreparedDeal::own`, read per (strategy, core) at load),
  not over what the selected strategies agree on: a field they disagree on (StopLossDelay 10 on
  one) no longer runs every deal at the schema default; a found value is a change when any
  strategy lacks it, and the MShotPrice "closer" warning reads every strategy's own value
- the variant columns are rescored when a reload carries every row: narrowing the selection used
  to leave V1/V2 and the plan column empty while the pane still drew the variants
- a report-driven reload no longer kills a running search (a trade closing on any core stopped a
  one-minute run silently); the search runs on its own copy and lands in V1
- the "trades ≥" floor is enforced: `suggest` answers nothing when no point keeps it, and a floor
  above the training slice is refused up front with the count; a scope change drops the last
  search's holdout from V1's caption
- tapes are kept packed, 8 bytes a print (ms offset, price, side in the sign bit; the model never
  reads the quantity), unpacked off the UI thread for a rescore, a search or the pane; the cap is
  counted in bytes and holds only fit rows' tapes. Measured on 265 MoonShot deals: 48.9 MiB held
  as `Tick`s, 16.3 MiB packed
- a scope reload re-reads a fit row whose tape the cap let go under a wider scope: narrowing from
  every strategy carried 33 fit rows without their tape, and the search ran on 93 of 126
- one `[x] ticks tape:` log line per load: rows, fit, fit with tape, let go by the cap, bytes
…search went

Entry/Exit axis search:

- "Keep the corridor no nearer the price" (search settings, on by default, persisted as
  `allow_closer_corridor` so an older config reads it on): a point whose resulting corridor —
  `bounds_pct`, both bounds, under the deltas the entry order lived through — comes nearer the
  price than a trade's own at any moment is out of the search; distance may move between
  `MShotPrice`, `MShotPriceMin` and the `MShotAdd*` modifiers, not shrink. Under the shift method
  only the far bound is held
- a point that inverts `MShotPriceMin >= MShotPrice` on a strategy that had them in order is never
  proposed (the model read it as a zero-width band; what the core does with it is unknown)
- `suggest` returns why it found nothing (`SearchMiss`: nothing, trade floor, corridor)
- restarts past the first start from the strategy moved a few grid steps on a few fields, each in
  its own field order, instead of a uniform random point that lost to restart 0 every time; a
  pass that moves no single field tries pairs of Entry number fields, one a step down and another
  a step up — a distance shared by two fields moves where no single move reached it
- `SearchStats` in the status band: restarts, the winning one, its passes and whether it
  converged, distinct ends (by the resulting parameters), restarts with no allowed point, points
  scored
- grids: `MShotAdd*` fine near zero then a 0.05 step to 1.0 (the old 0.2 ceiling could not reach
  a live 0.5), `MShotAddDistance` finer below 50

Save / Make a copy warn on the same rules (`check_corridors`): on how many of the replayed trades
V1's corridor is nearer the price, or its two fields inverted — replacing the MShotPrice-only
check, and only for a variant that moves an Entry field.
…ections

The parameter grid of the Entry/Exit axis follows the strategy editor: Strategy settings (where a
MoonShot's entry lives), Stops, Sell order, SellShot, SellSpread and Delta Modifiers, each with
every field the live schema files there for the scope's kinds. Visual only - the model and the
search read what they read before.

- `ParamSection` on every knob of `TICK_PARAMS` places it when no core with a schema is
  connected; a test pins it against `assets/param_deps.toml`
- the kind of a deal's strategy comes from the store's row by `kind_ordinal` (the report's
  `SignalType` is spelled differently); the report's signed `strategyid` is matched to the core's
  u64 id by its bits, so an id past `i64::MAX` finds its row too
- a field the search does not turn is drawn greyed with the strategies' value: one the model
  reads (`MODEL_ONLY_KEYS`) or one it does not know, said in its tooltip
- sections fold (folded by default, the field selected for Search stays in sight); a section's
  tick and the header's admit all their knobs, half-set when some are; rows are indented under
  their section at caption size, the section name one step larger
- the layout is published with the rows; a schema that arrives or changes after a load asks the
  refresh gate for one more load
- the variant cells take `design::dense_input_size`, one caption line tall, so a row with inputs
  stands as high as a greyed one; `MoonInputSize::Custom` builds the box from `height` only (its
  `h` goes to the multi-line height), which a headless layout test now holds
…l the trailing stop

The sell line lived in one 811-line `ticks/line.rs` plus the take in `exit.rs`. It is now one
file per section of the Strategies window under `ticks/exit/`: `stops.rs`, `sell_order.rs` (the
take, SellDelay, PriceDown, SellLevel), `sell_shot.rs`, `sell_spread.rs` (empty),
`delta_mods.rs`, PumpsDetection's `pump_move.rs`, and the shared step `line.rs`. The move kept
every deal of the real-data bench identical line for line; tests moved to the sections' sibling
files.

Stops:
- A short's stop level is `buy / (1 + StopLoss/100)`, not the long's product mirrored
  (`stops::stop_level`, one helper for the walk, the verdict, the fact's anchor and the bench):
  the core prints that level on 11 099 short stops of the report against 25 for the mirror.
  Bench on one data slice: exit ok 1734/1978 -> 1743/1980, fit for the search 1565 -> 1574.
- The trailing stop (`UseTrailing`) is modelled instead of leaving the trade unjudged
  (`exit/stops/trailing.rs`, per the core's answers): the line follows the peak of the ticker's
  mid price, which steps at most once a second by 1/(TrailingEMA + 1) and restarts at the end of
  StopLossDelay; UseTakeProfit holds the line back until TakeProfit + |TrailingPercent| and
  floors the sale at TakeProfit; a stop on the same arrival fires first. The verdict counts a
  "TrailingStop" reason as a stop exit and reads its archived panic sell against the line under
  the printed PeakPrice. All 8 trades under a trailing stop are now judged and reproduced;
  exit ok 1743/1980 -> 1751/1988, fit 1574 -> 1581.
…about exit fields outside the model

Sell order:
- Every level a short states in per cent of the buy is `buy / (1 + p/100)` - the take of every
  kind, the stop, the trailing take profit, the PriceDownAllowedDrop and SellLevelAllowedDrop
  floors - through one helper, `exit::level_off_buy` (was `stops::stop_level`). A PriceDown step
  without Relative, SellLevelAdjust off the high and the trailing distance stay products. The
  archived short lines stop on the divided floor 67 times against 1 for the product.
  Bench on one data slice: exit ok 1751/1988 -> 1764/1988, fit for the search 1581 -> 1594.
- SellLevel reads the look-back high over the tape AND the market's minute klines
  (`Deal::bars`, 4 h before the window, from `deltas::track_for`): off the tape alone an hour's
  look-back was the run-up's high. SellLevel, PriceDown and the pump move now fire in the order
  they fall due.

SellShot and SellSpread are not modelled (the developer's call): the SellShot walk, its fields
and knobs are gone; a strategy with either switched on - or with AutoSell off - is an
`UnmodelledRule` and its trades stay out of the verdict and the search. The grid draws both
sections muted, every row inactive, and says so in the heading.

Warning: `ticks/unmodelled.rs` lists the exit fields a strategy switches on that the model does
not have (dependency rule of `assets/param_deps.toml` holds and the value is off the core's
default); the rules' parser moved from the Strategies window into `feed/strategy_deps.rs`. The
axis shows them in a MoonUI dialog before a search (Cancel / Search anyway) and as warning lines
in the save and copy dialogs, and names a target the load has not read yet.

param_deps.toml: PriceDownToAllowedDrop (only with PriceDownRelative), SellEMACheckEnter (only
with a CustomEMA filter), SellEMADelay (only with SellByCustomEMA), MShotSellPriceAdjust (only
with MShotSellAtLastPrice).
…ves axis moves

- Model the second and third stops (exit/stops/ladder.rs): a step is read on
  the ticker's bid from max(sell placement + (T + 0.5) s, StopLossDelay end),
  moves the stop to its level off the buy (no StopLossModifier), once, the
  last step taken wins; the verdict reads the level the walk fired at.
- Stops knobs in the search: UseStopLoss, StopLossEMA, StopLossDelay,
  StopLoss, the ladder, the trailing; FastStopLoss stays read-only; the panic
  sell's execution fields are no knobs; drop the ladder and the liquidation
  guards from the unmodelled warning.
- Search honesty: a point needs a guard (stop, or trailing without take
  profit) on every strategy and must close every deal it buys inside the
  tape, else it is refused (SearchMiss::Unclosed); a switch it turns on
  brings the values param_deps.toml says it needs; fields in effect nowhere
  are dropped; the holdout says how many deals it left open; the write
  dialogs warn about unguarded targets.
- Delta modifiers per the core: |sum| capped by MaxModifier, BTC deltas as
  magnitudes, SellModifier shifts the placed sell (short divides), the
  MoonShot corridor sum is capped by MaxModifier and PriceBug by 30 %.
- Keep a running Entry/Exit search across report-axis moves (a core adopting
  its clock offset stopped it every ~30 s after a start); repaint its
  progress on a 250 ms poll; log why a search stopped.
- Right column: empty with a note when no strategy is selected; short
  "not modelled" section note with the long text in its tooltip.
- MOON_TUNER_SEARCH_PROBE: env-gated probe that presses Search and logs the
  run's progress beside what the row paints.
…r corridor grid

- Grid rows: the search knobs always; any other field only when a strategy of
  the scope switches it on (its rule holds and it is off the kind's schema
  default, the Strategies window's reading, shared with the unmodelled
  warning); a section left empty is not drawn. The Delta Modifiers tab counts
  as used only when an Add* term and a modifier applying it are both on. Every
  schema field is read, so rule conditions outside the grid are real values.
- Search / Search all: tooltips say what each varies and where the answer
  lands; a one-field answer lands whole in V1, the values it completed with it.
- Completion: a number field is completed only where the variant puts it in
  effect. A field absent from the dump is at the core's default, not missing:
  completing it at the other strategies' median rewrote the strategy itself
  and the corridor rule refused the base (53 of 136 deals). Every condition a
  number knob's rule reads has a fallback (MShotSellAtLastPrice added), held
  by a test. The start step reads the lowercase schema defaults.
- Deals the strategies as they stand leave open inside the tape are out of the
  search's sample (restart 0's own point decides), shown in the status line,
  instead of refusing every point.
- Model popover, new Sample section: the shortest tape past the close a deal
  must hold to be worked on (default 60 s, capped by the Storage margin); a
  shorter one is out of the fit baseline, V1/V2 and the search, counted in
  the footer. Old tapes cannot be fetched again; a 30 s tail cut every
  variant's exit at 30 s and left the entry unsearchable.
- MShotPrice and MShotPriceMin grids: 0.05 to 8 in steps of 0.05.
- Log: the base against the best, and how many points each rule refused.
The exit model already applied SellModifier, StopLossModifier, MaxModifier
and the Add* terms, but read them as fixed. They are now knobs of the
Entry/Exit grid, with grids taken from the live strategies' values.

- MaxModifier stays fixed for MoonShot: there it also caps the MShotAdd*
  corridor, which an exit search must not move.
- The search treats the section as a product: a field that moves nothing
  at the point on every strategy is not scanned, and a coefficient that is
  off with no term set is walked together with each term along a diagonal
  of their grids, so the section can be switched on from zero.
- A write's corridor warning now also keys on MaxModifier
  (params::moves_entry).
- The real-data bench takes MOON_TICKS_SEARCH=<kind> to run the section's
  search per strategy on a replica.
…e grid, V2 gone

The hand-kept GRID_* ladders are gone. A number field's search grid now
comes from what the live strategies hold of it (params/range.rs), so a
field added later needs no per-field rule.

- Automatic range: the 5th to 95th percentile of the field's values among
  the live strategies of the scope's kinds (current versions of the ones
  not deleted, one per content_hash, only where the field is in effect by
  param_deps and differs from the schema default; every kind under 20
  values), widened to the default and the selected strategies' values.
- Cut into about N steps ("Steps per field" in the search settings,
  default 20, persisted): the step rounded up to 1, 2, 2.5 or 5 x 10^k and
  never finer than the digits the values carry; values rounded and
  deduplicated, so a step finer than the field gives each value once. The
  selected strategies' own values join the grid exactly. An edge is never
  snapped across zero: 0 is "no stop" for StopLoss and a corridor of
  nothing for MShotPrice.
- The grid shows from / to / step beside each number knob, the value the
  search takes greyed in; a typed slot overrides it. A square reset per row,
  per section and in the header takes ranges back to automatic. Typed
  ranges persist in TicksAxisLayout.ranges; a malformed entry drops alone.
  An unusable one (from above to, step <= 0, over 200 values, a step with
  no edges) is framed red and the search says it did not take it.
- The search takes its grids at start (SearchParams::grids); a number field
  with no grid is not varied.
- The second variant column is gone from the axis: one variant in the
  state, the KPI, the plan column and the trade pane.
- Real-data bench: MOON_TICKS_GRIDS=legacy|auto, MOON_TICKS_STEPS,
  MOON_TICKS_SEARCH_ALL. On this machine's replica (5 MoonShot strategies,
  whole exit, 10 restarts) the old ladders fit 587 / hold out 181 / whole
  768, the automatic grids 537 / 230 / 767, in 4.4 s instead of 11.6 s.
  The ladders stay only as a test fixture (search/test_grids.rs).
"Search all" searched from the strategies as they stand and replaced V1
with its answer, so a search chained after another lost what the first
had found. Every search now lays V1's edits over each deal's strategy
(SearchParams::held): a ticked field starts from V1's value, an unticked
one is held at it. The answer is laid over V1 in both searches: a cell the
search did not move keeps V1's value, a field moved back to the
strategy's own value is written so. Only the user clears V1.
…he run

"Search all" with Entry and Exit fields ticked no longer runs one coordinate
descent over both groups: it settled where an entry move, judged under the
exit tuned for the old entry, always read as worse, so the entry never moved
(the bench and the app log of 2026-09-25). Every entry point is now scored by
a whole exit descent under it (search/nested.rs): warm-started from the exit
of the best entry point so far, cached per restart, an entry point the
corridor rules refuse cut before its exit is searched. One group ticked runs
the plain descent as before.

- search/size.rs: the points a search scores and what one costs, measured
  side by side as the restarts run; the grid shows "Search all: ~time,
  ~N variants" under the fields, wrapped to two lines, and a run estimated
  past 10 minutes asks before it starts.
- The status and the stats say how many entry points were scored.
- The warning about exit fields the model lacks opens only before a search
  that varies an exit field.
- A searched field starts from the strategies whatever V1 holds; its V1 cell
  takes the answer or is emptied when the answer is the strategy's own value
  (SearchResult::searched). Unticked fields stay held at V1.
- The real-data bench searches the groups alone or together
  (MOON_TICKS_SEARCH_ENTRY, _TOP, _DRY) and prints the estimate beside the run.
…the trade pane

- ticks/accuracy.rs: once a search has run, the grid says how much of the
  scope's tape the model reproduces - entry and exit hits over every trade
  with tape, misses and unjudged trades counted against it; the tooltip
  carries the per-group counts, the fit sample the search learned on, the
  trades without tape and the model's assumptions with the numbers it runs
  under.
- The trade pane draws V1's sell order as the exit model walked it
  (VariantPicture::sell_line), stepped from the fill to the close like the
  entry path; a path placed only after the close falls back to the flat
  exit line.
- A row filed in the other group's section says which group searches it.
- Trade windows and the tuner's trade pane each get a "Trade captions on
  the chart" switch (trade_window_labels / analytics_trade_labels; absent
  reads ON in a window, OFF in the pane).
- Added a new module `gap` to manage the concept of gaps in long position tapes.
- Implemented `TapeGap` struct to represent the hole between held ends of a long position.
- Updated `Deal` struct to include an optional `gap` field.
- Enhanced exit handling to account for gaps, introducing `ExitKind::InGap`.
- Modified simulation logic to recognize when a deal's exit occurs within a gap.
- Updated various tests to validate the new gap functionality and its integration with existing logic.
- Adjusted localization files to reflect changes in model assumptions regarding gaps.
…depth off the fact

The axis rounded every modelled level to the live catalog's `price_step`, which
is moonproto's `chart_price_step` = max(eps, ask / 5000): the chart's own step,
off the exchange grid (COOL 0.00000038 against a tick of 0.000001, 77 of 77 rows
of one load). The take and every PriceDown step left the core's grid, and the
axis read 65 % where the real_data bench, which reads the grid off the prints,
read 92 % on the same trades. Deal::tick is now always infer_tick over the held
tape; RowAddress loses its tick; the doc of MarketDataSource::price_step says
what it is.

The delta-modifier sum Σ the core spends on the take (SellModifier) and the stop
(StopLossModifier) is read back off the trade's own record instead of rebuilt
from deltas the report snapshots up to minutes before the sell:

- exit/delta_mods.rs FactModifier: the archived take, the sale of a trade its
  untouched take closed, or `StopLoss fixed: X` give the band of sums whose level
  rounds to the recorded price (take and stop overlapped where both exist); the
  model's own sum is clamped into it, and the miss is carried to every variant,
  scaled by the terms' total |coefficient|. A sum read at MaxModifier keeps only
  its lower bound. On 96 trades holding both readings they agree to 0.021.
- record.rs placed_hook_depth: a MoonHook's take is placed off the comment's
  stated `SellPrice: Y%` over HookSellLevel - the comment's `Depth` is written at
  the close, and ran a tenth to a half short of the core's take.
- take_replay also carries the placed depth, the modifier sum and the tape gap
  (the gap never reached the stored row the variants replay).
- ticks load logs one "[x] ticks replay" line per load: rows, covered, exit
  share, step lag per core - to hold against the bench.
- real_data bench: MOON_TICKS_DEFAULTS feeds the schema's field defaults; the
  dump carries whether a deal holds a reading of the core's sum.

real_data, same database: MoonHook exit 83.6 % -> 91.7 % (take misses 67 -> 3),
fit for the search 717 -> 787 of 874, own-parameters replay on the fact 434 -> 549;
MoonShot, Spread and PumpsDetection unchanged.
…s text

Bases::moves compared the search's spelling of a value with the strategy's as
text, so PriceDownTimer `1.0` answered as `1` landed in В1 as a change (and Save
would have written it). It now compares through unmodelled::same_value - booleans
as booleans, numbers as numbers, else case-insensitive text. A base that leaves
the field out, or holds it blank (which same_value reads as a boolean false),
still moves on any value, so a value search::deps completes for a switch the
search turned on is written with it.
MShotSellAtLastPrice lifts a MoonShot take to the ask before the spike. With no
archived Exit line the model read that ask off the tape as the last print of
either side at least PRE_SPIKE_LOOKBACK_MS before the fill - half a spread under
the ask on a dump. pre_spike_price now takes the last taker BUY (a taker buy
prints at the ask) by the cutoff, at most PRE_SPIKE_BUY_WINDOW_MS (60 s) before
it, else the last print of either side. Longs and shorts alike: the core lifts a
short's take off the ASK as well.

Against the ask the archive gives back on 1 099 MoonShot trades (2026-09-26), at
4 s: within 0.05 % on 46 % of longs and 48 % of shorts, against 38 % and 36 %
before; Gate stays near 15 %. Still no ground for a verdict, so take_known is
unchanged. real_data bench: no verdict flips; the model's take on the 42
take-closed trades without an archive lands within 0.05 % of the sale on 12,
against 7.
The delta-modifier sum reads BTC's deltas live where the deal has a track, not
the snapshot (delta_mods.rs, verify.rs); the live-strategy counts behind
MaxModifier and StopLossModifier are the 2026-09-25 ones.
A group (Entry / Exit) under the reproduction share (gate_pct, 80 % by default)
was refused by the search. The search already learns on the fit trades alone
(fit_for_search) and the accuracy line says how much of the history that is, so
the gate now only warns (LinKvo, 2026-09-26):

- TicksData::group_searchable: the kinds have a model of the group and at least
  one trade is fit for the search - an all-miss group has nothing to learn on;
- TicksData::under_gate: the group heading, both search tooltips and the Save /
  Copy dialogs (when the variant changes fields of such a group) say the search
  learns on the fit trades alone - a variant searched on a small share can reach
  a live strategy, and the dialog is the last place to say so;
- a single-field refusal names its cause: no entry model for the kind
  (sugg_no_entry_model) or no fit trade (sugg_unanswered, was sugg_gated);
- the setting reads "Warning share, %"; the search tooltips count the fields of
  the searchable groups once per group, not per knob, as they build every paint.
Seventeen findings on lines this branch added, none of them a behavior
change: a counted loop over the step's power, is_multiple_of, struct update
in six tests, a needless borrow of Entity::read, a unit let around
AsyncApp::update, a let-chain, and an allow on start_replay_stage's eight
arguments as elsewhere in the tree.
@guyverino
guyverino merged commit 7804dfc into main Sep 26, 2026
8 checks passed
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