From 0d9bc58bed1217459f1fc9cc3df3087e4b385ca5 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sun, 20 Sep 2026 12:28:23 +0200 Subject: [PATCH 01/51] feat(tuner): tape-replay model for the Entry/Exit axis, MoonShot entry 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 %. --- crates/moon-core/src/db/tuner/mod.rs | 2 +- .../moon-core/src/db/tuner/strategy_read.rs | 94 +- .../src/db/tuner/strategy_read/tests.rs | 72 ++ crates/moon-core/src/db/tuner/ticks/entry.rs | 43 + crates/moon-core/src/db/tuner/ticks/exit.rs | 128 +++ crates/moon-core/src/db/tuner/ticks/mod.rs | 326 +++++-- crates/moon-core/src/db/tuner/ticks/mshot.rs | 396 +++++++++ crates/moon-core/src/db/tuner/ticks/params.rs | 324 +++++++ crates/moon-core/src/db/tuner/ticks/tests.rs | 803 ++++++++++++++++++ .../src/db/tuner/ticks/tests/real_data.rs | 216 +++++ crates/moon-core/src/db/tuner/ticks/verify.rs | 112 +++ 11 files changed, 2464 insertions(+), 52 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/strategy_read/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/entry.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/mshot.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/params.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/tests/real_data.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/verify.rs diff --git a/crates/moon-core/src/db/tuner/mod.rs b/crates/moon-core/src/db/tuner/mod.rs index 13930792..c25b65b4 100644 --- a/crates/moon-core/src/db/tuner/mod.rs +++ b/crates/moon-core/src/db/tuner/mod.rs @@ -30,7 +30,7 @@ mod time; pub use fields::{FIELDS, FieldClass, FieldSpec, slot_type_for}; pub use strategy_read::{ StratFilters, strategy_cores, strategy_current_values, strategy_current_values_opt, - strategy_filters, strategy_kinds, + strategy_filters, strategy_values_at, }; pub use time::{ SliderProfiles, TimeAxes, TimeSuggest, TimeWindow, format_week_span, format_working_time, diff --git a/crates/moon-core/src/db/tuner/strategy_read.rs b/crates/moon-core/src/db/tuner/strategy_read.rs index 99c0de8e..9efb8113 100644 --- a/crates/moon-core/src/db/tuner/strategy_read.rs +++ b/crates/moon-core/src/db/tuner/strategy_read.rs @@ -143,10 +143,97 @@ pub fn strategy_current_values_opt( core: Option, keys: &[String], ) -> Option> { - let mut out = std::collections::HashMap::new(); let conn = open_strategies_ro()?; let raw = load_head_raw_json(&conn, strategy_id, core)?; - let Ok(serde_json::Value::Object(map)) = serde_json::from_str(&raw) else { + flatten_values(&raw, keys) +} + +/// The values of `keys` as of `at_ms` — from the version whose `[valid_from, valid_to)` holds +/// that moment. A trade older than the first recorded version reads the FIRST version, the +/// closest thing on record to what ran then; `None` as in [`strategy_current_values_opt`]. +/// +/// The Entry/Exit tuner runs its model on the parameters a trade was actually made under; the +/// head would silently judge yesterday's fill by today's corridor. +/// +/// Args: +/// strategy_id: The strategy. +/// core: Its core, when known; rows are per-core. +/// at_ms: The moment, Unix ms — the trade's `buydatems`. +/// keys: Field names to read. +pub fn strategy_values_at( + strategy_id: i64, + core: Option, + at_ms: i64, + keys: &[String], +) -> Option> { + let conn = open_strategies_ro()?; + let raw = load_raw_json_at(&conn, strategy_id, core, at_ms)?; + flatten_values(&raw, keys) +} + +/// The `raw_json` of the version valid at `at_ms`, else the earliest version on record, scoped +/// to `core` when known. `None` when the strategy has no version at all. +fn load_raw_json_at( + conn: &Connection, + strategy_id: i64, + core: Option, + at_ms: i64, +) -> Option { + // Two spellings per query rather than one with an optional clause: the placeholder + // numbering shifts with the core clause, and a `?3` bound to nothing is a silent `None` + // that would send every core-less read to the first version. + let at = match core { + Some(c) => conn + .query_row( + "SELECT v.raw_json FROM strategy_versions v + WHERE v.strategy_id = ?1 AND v.core_uid = ?2 + AND v.valid_from <= ?3 AND (v.valid_to IS NULL OR v.valid_to > ?3) + ORDER BY v.valid_from DESC LIMIT 1", + rusqlite::params![strategy_id, c as i64, at_ms], + |r| r.get(0), + ) + .ok(), + None => conn + .query_row( + "SELECT v.raw_json FROM strategy_versions v + WHERE v.strategy_id = ?1 + AND v.valid_from <= ?2 AND (v.valid_to IS NULL OR v.valid_to > ?2) + ORDER BY v.valid_from DESC LIMIT 1", + rusqlite::params![strategy_id, at_ms], + |r| r.get(0), + ) + .ok(), + }; + if at.is_some() { + return at; + } + match core { + Some(c) => conn + .query_row( + "SELECT v.raw_json FROM strategy_versions v + WHERE v.strategy_id = ?1 AND v.core_uid = ?2 + ORDER BY v.valid_from ASC LIMIT 1", + rusqlite::params![strategy_id, c as i64], + |r| r.get(0), + ) + .ok(), + None => conn + .query_row( + "SELECT v.raw_json FROM strategy_versions v + WHERE v.strategy_id = ?1 + ORDER BY v.valid_from ASC LIMIT 1", + rusqlite::params![strategy_id], + |r| r.get(0), + ) + .ok(), + } +} + +/// `keys` out of one version's `raw_json`, in strategy format — see +/// [`strategy_current_values_opt`] for the rules. +fn flatten_values(raw: &str, keys: &[String]) -> Option> { + let mut out = std::collections::HashMap::new(); + let Ok(serde_json::Value::Object(map)) = serde_json::from_str(raw) else { return None; }; for key in keys { @@ -328,3 +415,6 @@ pub fn strategy_filters( } out } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/strategy_read/tests.rs b/crates/moon-core/src/db/tuner/strategy_read/tests.rs new file mode 100644 index 00000000..54dc74d9 --- /dev/null +++ b/crates/moon-core/src/db/tuner/strategy_read/tests.rs @@ -0,0 +1,72 @@ +//! The as-of version read, on an in-memory copy of the `strategy_versions` shape. + +use super::*; + +fn versions() -> Connection { + let conn = Connection::open_in_memory().expect("memory db"); + conn.execute_batch( + "CREATE TABLE strategy_versions ( + core_uid INTEGER, strategy_id INTEGER, valid_from INTEGER, valid_to INTEGER, + raw_json TEXT); + INSERT INTO strategy_versions VALUES + (7, 42, 1000, 2000, '{\"MShotPrice\": 1.0}'), + (7, 42, 2000, 3000, '{\"MShotPrice\": 2.0}'), + (7, 42, 3000, NULL, '{\"MShotPrice\": 3.0}'), + (8, 42, 1500, NULL, '{\"MShotPrice\": 8.0}');", + ) + .expect("schema"); + conn +} + +fn price(raw: Option) -> Option { + let raw = raw?; + let json: serde_json::Value = serde_json::from_str(&raw).ok()?; + json.get("MShotPrice")?.as_f64() +} + +#[test] +fn the_version_valid_at_the_moment_is_read() { + let conn = versions(); + assert_eq!(price(load_raw_json_at(&conn, 42, Some(7), 1000)), Some(1.0)); + assert_eq!(price(load_raw_json_at(&conn, 42, Some(7), 1999)), Some(1.0)); + assert_eq!(price(load_raw_json_at(&conn, 42, Some(7), 2000)), Some(2.0)); + assert_eq!(price(load_raw_json_at(&conn, 42, Some(7), 5000)), Some(3.0)); +} + +#[test] +fn a_moment_before_the_first_version_reads_the_first_version() { + let conn = versions(); + assert_eq!(price(load_raw_json_at(&conn, 42, Some(7), 10)), Some(1.0)); +} + +#[test] +fn the_core_scopes_the_read_and_no_core_reads_across_cores() { + let conn = versions(); + assert_eq!(price(load_raw_json_at(&conn, 42, Some(8), 4000)), Some(8.0)); + // Without a core, the latest-starting version valid at the moment wins, whichever core. + assert_eq!(price(load_raw_json_at(&conn, 42, None, 2500)), Some(2.0)); + assert_eq!(price(load_raw_json_at(&conn, 42, None, 1600)), Some(8.0)); + assert_eq!(load_raw_json_at(&conn, 99, None, 2500), None); +} + +#[test] +fn flatten_values_spells_booleans_and_lists_in_strategy_format() { + let raw = r#"{"MShotPrice": 1.5, "MShotMinusSatoshi": true, "CoinsBlackList": ["A", "B"]}"#; + let keys = [ + "MShotPrice", + "MShotMinusSatoshi", + "CoinsBlackList", + "Missing", + ] + .map(String::from) + .to_vec(); + let out = flatten_values(raw, &keys).expect("object"); + assert_eq!(out.get("MShotPrice").map(String::as_str), Some("1.5")); + assert_eq!( + out.get("MShotMinusSatoshi").map(String::as_str), + Some("YES") + ); + assert_eq!(out.get("CoinsBlackList").map(String::as_str), Some("A,B")); + assert!(!out.contains_key("Missing")); + assert_eq!(flatten_values("[]", &keys), None); +} diff --git a/crates/moon-core/src/db/tuner/ticks/entry.rs b/crates/moon-core/src/db/tuner/ticks/entry.rs new file mode 100644 index 00000000..b4c4fe50 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/entry.rs @@ -0,0 +1,43 @@ +//! The entry side: "where would the buy order have stood, and which print would have filled +//! it" — one implementation per strategy kind whose entry is a function of the tape alone. +//! +//! A kind is listed here only when its buy order's position can be derived from the prints and +//! the strategy's own parameters, with no order book and no core state. MoonShot qualifies (a +//! limit at a fixed distance below the price, walking a corridor); a Combo, a Strike or a +//! volume detect does not — their entry is a detect the core made on data the tape does not +//! carry, and the axis takes it from the report as it happened. MoonHook (the same corridor, +//! `Hook*` fields) is the first candidate for a second implementation; it adds one arm to +//! [`entry_model_for`] and nothing to the UI. + +use super::mshot::MshotEntry; +use super::{Deal, Fill}; +use crate::feed::types::Tick; + +/// The kind name of MoonShot as the strategy list spells it (`feed::strategies` ordinal 6). +pub const KIND_MOONSHOT: &str = "MoonShot"; + +/// An entry model: given the tape, where the order would have filled. +pub trait EntryModel { + /// The fill, or `None` when no print reached the order over the whole tape. + /// + /// Args: + /// deal: The report row; the model reads its deltas, side and price step. + /// ticks: The window's prints, ascending by time. + /// start: `(t_ms, price)` the real order was first seen at, from the order archive, + /// when known — the model starts there instead of at the tape's first print. + fn fill(&self, deal: &Deal, ticks: &[Tick], start: Option<(i64, f64)>) -> Option; +} + +/// Whether the kind has an entry model at all — the UI's "Entry group available" test. +/// +/// Args: +/// kind: The strategy kind name as the list carries it. +pub fn entry_model_for(kind: &str) -> bool { + kind == KIND_MOONSHOT +} + +impl EntryModel for MshotEntry<'_> { + fn fill(&self, deal: &Deal, ticks: &[Tick], start: Option<(i64, f64)>) -> Option { + self.run(deal, ticks, start) + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs new file mode 100644 index 00000000..35f0ba53 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -0,0 +1,128 @@ +//! The exit side: where the sell line stood after the fill, and which print crossed it. One +//! model for every strategy kind — after the entry filled, the exit of any strategy is a +//! function of the sell-order rules and the tape. +//! +//! Phase 1 carries the take-profit alone: `SellPrice` per cent above the fill, raised by +//! `MShotSellAtLastPrice` to the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the +//! 4-second-old ASK, i.e. before the spike"; the model reads the last print at least +//! [`PRE_SPIKE_LOOKBACK_MS`] before the fill, since the tape has no book). A position the take +//! never closed exits AS THE REPORT SAYS IT DID — [`ExitKind::Fact`] — which the caller shows as +//! "exit not modelled" rather than as a reproduction. The moving line (`PriceDown*`, +//! `SellLevel*`, `SellShot*`, `StopLoss`) is phase 2, checked against the archived Exit lines +//! before it is trusted. + +use super::mshot::PRE_SPIKE_LOOKBACK_MS; +use super::{Deal, Exit, ExitKind, Fill, reaches}; +use crate::feed::types::Tick; + +/// Sell-line parameters, in the strategy's own units. +#[derive(Clone, Debug, PartialEq)] +pub struct ExitParams { + /// `SellPrice` — take-profit distance from the fill, per cent. + pub sell_price_pct: f64, + /// `MShotSellAtLastPrice` — lift the take to the pre-spike price less the adjustment. + pub sell_at_last_price: bool, + /// `MShotSellPriceAdjust` — per cent SUBTRACTED from the pre-spike price. + pub sell_price_adjust_pct: f64, + /// `SellDelay` — milliseconds the core waits before placing the sell; prints inside the + /// delay cannot fill it. + pub sell_delay_ms: f64, +} + +impl Default for ExitParams { + fn default() -> Self { + Self { + sell_price_pct: 1.0, + sell_at_last_price: false, + sell_price_adjust_pct: 0.0, + sell_delay_ms: 0.0, + } + } +} + +/// The exit model over one parameter set. +pub struct ExitModel<'a> { + params: &'a ExitParams, +} + +impl<'a> ExitModel<'a> { + pub fn new(params: &'a ExitParams) -> Self { + Self { params } + } + + /// The take-profit level for a fill: `SellPrice` off the fill, lifted to the pre-spike + /// print less the adjustment when `MShotSellAtLastPrice` is on. Long above, short below. + pub fn take_level(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> f64 { + let by_pct = fill.price * self.params.sell_price_pct / 100.0; + let mut take = if deal.is_long() { + fill.price + by_pct + } else { + fill.price - by_pct + }; + if self.params.sell_at_last_price { + if let Some(pre) = pre_spike_price(ticks, fill.t_ms) { + let adjust = pre * self.params.sell_price_adjust_pct / 100.0; + take = if deal.is_long() { + take.max(pre - adjust) + } else { + take.min(pre + adjust) + }; + } + } + take + } + + /// Replay the tape after the fill. + /// + /// Args: + /// deal: The report row — its side, and its own exit for the fallback. + /// ticks: The window's prints, ascending. + /// fill: The modelled (or factual) entry. + pub fn exit(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> Exit { + let take = self.take_level(deal, ticks, fill); + let armed_at = fill.t_ms + self.params.sell_delay_ms.max(0.0) as i64; + for tick in ticks { + let t_ms = tick.time_ms as i64; + // A print at the fill's own millisecond is the fill itself, not the exit. + if t_ms <= armed_at || t_ms <= fill.t_ms { + continue; + } + let price = f64::from(tick.price); + // A long's take is reached from below by a print coming UP, a short's from above. + if price > 0.0 && reaches(price, take, deal.is_short) { + return Exit { + t_ms, + price: take, + kind: ExitKind::Take, + }; + } + } + // No rule of this phase closed it. The report's exit is the honest stand-in while the + // fill is the factual one; a modelled fill that differs from the fact makes the fact's + // exit a guess — the caller keeps that distinction (`Verdict::exit` is `None` here). + let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(fill.t_ms); + if deal.close_ms > fill.t_ms && deal.close_ms <= tail && deal.sell_price > 0.0 { + return Exit { + t_ms: deal.close_ms, + price: deal.sell_price, + kind: ExitKind::Fact, + }; + } + Exit { + t_ms: tail, + price: f64::NAN, + kind: ExitKind::OpenAtWindowEnd, + } + } +} + +/// The last print at least [`PRE_SPIKE_LOOKBACK_MS`] before `at_ms` — the FAQ's "price before +/// the spike". +pub fn pre_spike_price(ticks: &[Tick], at_ms: i64) -> Option { + let cutoff = at_ms - PRE_SPIKE_LOOKBACK_MS; + ticks + .iter() + .rev() + .find(|t| (t.time_ms as i64) <= cutoff && t.price > 0.0) + .map(|t| f64::from(t.price)) +} diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index cf313e3d..53c5e6f9 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -1,65 +1,293 @@ //! The "Entry/Exit" tuning axis — a what-if evaluated by REPLAYING the trade tape around each //! closed trade rather than by an SQL mask over report fields. //! -//! What lives here so far is the part the trade tape itself is kept by: which report rows the -//! axis reads ([`scope`]) and the stretch of prints each of them needs ([`model_window_at`]). -//! The Storage tab's cleanup keeps exactly that, so what the axis will fetch is what it keeps. +//! Two groups of parameters, one variant column: an **entry model** decides where the buy order +//! would have stood and when a print would have filled it (only strategy kinds whose mechanics +//! are a function of the tape have one — MoonShot today, see [`entry`]), and an **exit model** +//! decides where the sell line stood and when a print crossed it (the same for every kind, see +//! [`exit`]). A kind without an entry model takes its entry from the report row as it happened. +//! +//! Everything here is a pure function of one report row, its prints and the parameters; the +//! rows, the prints and the order traces come from the caller. Nothing opens a database — the +//! callers already run the axis' reads on a background executor, and the search of phase 2 calls +//! [`simulate`] thousands of times per second. +//! +//! The model is NOT a backtest: the core is not in the loop, and the sample is only the fills +//! that happened. A variant that puts the order DEEPER is judged honestly (the tape says whether +//! the spike reached the new level); a variant that puts it CLOSER is underestimated, because +//! spikes the real order never reached are not in the report at all. The caller writes that +//! into the column caption; the model does not hide it. +//! +//! Sources of the mechanics: the Moonbot FAQ (`data/faqru.tsv`, "Какие есть специфические +//! параметры у стратегии MoonShot" and the `PriceDown*` / `SellLevel*` / `SellShot*` answers), +//! checked against the live `strategies.sqlite` field names on 2026-09-20. -use crate::market::trade_replay::{ReplayWindow, replay_window_ms}; +use crate::feed::types::Tick; -pub mod scope; +pub mod entry; +pub mod exit; +pub mod mshot; +pub mod params; +pub mod verify; -pub use scope::{is_service_row, is_tunable}; +pub use entry::{EntryModel, entry_model_for}; +pub use exit::{ExitModel, ExitParams}; +pub use mshot::{MshotEntry, MshotParams, UsePrice}; +pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; +pub use verify::{Verdict, verify}; -/// When an entry order's life began, for a model that replays it whole: its creation stamp, -/// unless the order waited longer than [`ORDER_WAIT_CAP_MS`] — then its early life is not -/// fetched and the model starts where the tape does. One rule for every kind of the tuner, the -/// ones without an entry model too: their tape is fetched and kept for a model to come. +/// Relative tolerance under which a modelled price counts as reproducing the fact: 0.05 %. +/// +/// One price step on a mid-priced coin is well under this, and the tape carries `f32` prices, +/// whose 7 significant digits sit an order of magnitude below it too. +pub const PRICE_TOLERANCE: f64 = 0.0005; + +/// Relative slack when a print is held against a level: the tape carries `f32` prices, so a +/// print AT the level can sit a few units in the seventh digit past it. Well under any price +/// step, so it never turns a miss into a fill. +pub const PRICE_EPS: f64 = 1e-6; + +/// Whether a print at `price` reaches a level at `level` from the position's side — at or +/// below for a long buy or a short take, at or above for the mirror — with [`PRICE_EPS`]. /// /// Args: -/// buy_ms: The fill of the entry. -/// buy_set_ms: The order's creation, on the same clock (`buysetdatems`). -pub fn order_open_at(buy_ms: i64, buy_set_ms: Option) -> Option { - buy_set_ms.filter(|&set| set <= buy_ms && buy_ms - set <= ORDER_WAIT_CAP_MS) +/// price: The print. +/// level: The order. +/// from_below: `true` when the print must come DOWN to the level (a long entry, a short +/// take); `false` when it must come up. +pub fn reaches(price: f64, level: f64, from_below: bool) -> bool { + if from_below { + price <= level * (1.0 + PRICE_EPS) + } else { + price >= level * (1.0 - PRICE_EPS) + } } -/// The longest wait of an entry order the tape is fetched for, from its creation to its fill. -/// MoonShot orders on this machine's reports (2026-09-23, 289 with a creation stamp) waited a -/// median 114 s, 280 s at the 90th percentile and hours at the 99th; the cap keeps the few that -/// wait for hours from asking the venue for hours of prints, and they replay as before. -pub const ORDER_WAIT_CAP_MS: i64 = 10 * 60_000; - -/// The replay window of a trade as the model needs it: from the entry order's creation -/// ([`order_open_at`]) through the close, one stretch. Where that stretch would be walked as its -/// two ends — the order's life plus the position outrun `long_position_ms` — the window opens at -/// the fill as before: an entry end around the creation would leave the fill itself between -/// the ends, where nothing is fetched. The tape cleanup claims by this same rule -/// (`trades_cleanup`), so what the tuner fetched is what it keeps. +/// The report-side deltas the MoonShot modifiers read, as of the BUY of the trade. +/// +/// The report stamps them once, at the buy; the model treats them as constant over the window, +/// which is a stated assumption — on a 5-minute window a 1-hour delta barely moves, a 1-minute +/// delta can. All values are per cent, exactly as `orders_rep` stores them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Deltas { + pub d1m: f64, + pub d5m: f64, + pub d15m: f64, + pub d1h: f64, + pub d3h: f64, + pub d24h: f64, + /// Mark-price delta (`dmark`). + pub dmark: f64, + /// Price-bug measure (`pricebug`), a positive lag figure the exchange showed at the buy. + pub pricebug: f64, + /// BTC 1-hour delta (`btc1hdelta`). + pub btc1h: f64, + /// BTC 5-minute delta (`btc5mdelta`). + pub btc5m: f64, + /// Exchange-wide 1-hour delta (`exchange1hdelta`). + pub market1h: f64, +} + +/// One closed trade as the model needs it — the report row narrowed to the fields the tape +/// replay reads, plus what the caller knows about the market. +#[derive(Clone, Debug, PartialEq)] +pub struct Deal { + /// `reportuid` — the key of the order-trace archive and of the coverage map. + pub report_uid: i64, + pub core_uid: u64, + pub strategy_id: i64, + /// Strategy kind as the strategy list names it (`"MoonShot"`, `"Spread"`, …); selects the + /// entry model through [`entry_model_for`]. + pub kind: String, + /// The coin as the report spells it (`coin`, e.g. `BEN`); the market (`BEN_USDT`) is the + /// caller's to resolve through `symbol::` for the tape lookup. + pub coin: String, + /// `buydatems` — the fill of the entry, Unix ms. Rows without a millisecond stamp are not + /// deals for this axis; the caller drops them and counts them. + pub buy_ms: i64, + /// `closedatems` — the fill of the exit, Unix ms. + pub close_ms: i64, + pub buy_price: f64, + pub sell_price: f64, + /// `spentbtc` — what the entry cost, in the row's quote currency; the money KPI of a variant + /// is `profit_pct * spent` so the columns stay in the units of the "Fact" column. + pub spent: f64, + pub is_short: bool, + /// `sellreason` as the core wrote it (`"Auto Price Down"`, `"Sell Price"`, …). + pub sell_reason: String, + pub deltas: Deltas, + /// Price step of the market, when the caller could resolve it (the live catalog, or + /// [`infer_tick`] over the window). `None` disables the step-bound rules and rounds nothing. + pub tick: Option, +} + +impl Deal { + /// Whether the position is long; the mirror of every level rule keys off this. + pub fn is_long(&self) -> bool { + !self.is_short + } +} + +/// Where and when the modelled entry order filled. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Fill { + /// Time of the print that reached the level, Unix ms. + pub t_ms: i64, + /// The level itself — a limit fills at its own price, never at the print's. + pub price: f64, +} + +/// How a modelled position closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitKind { + /// A print reached the take-profit level. + Take, + /// A print crossed the moving sell line (PriceDown / SellLevel / SellShot) — phase 2. + Line, + /// The stop-loss level was crossed — phase 2. + Stop, + /// No exit rule decided; the exit is the report's own (`sellprice` at `closedatems`). A + /// phase-1 placeholder the caller shows as "exit not modelled". + Fact, + /// Nothing closed the position before the tape ran out. Not a trade: excluded from the KPI + /// and counted in the caption. + OpenAtWindowEnd, +} + +/// Where and when the modelled position closed, and by which rule. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Exit { + pub t_ms: i64, + pub price: f64, + pub kind: ExitKind, +} + +/// One trade's modelled outcome under one parameter set. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Outcome { + /// `None` — the entry never filled on this tape; there is no trade and the KPI does not + /// count it (the caption's "by N of M" is where that shows). + pub fill: Option, + pub exit: Option, + /// Signed result in per cent of the fill price, sign already flipped for a short. `None` + /// without a fill, with an [`ExitKind::OpenAtWindowEnd`] exit, or when either price is not + /// a price (see [`profit_pct`]) — none of those is a trade. + pub profit_pct: Option, +} + +impl Outcome { + /// Money result in the row's quote currency, or `None` when the outcome is not a trade. + pub fn profit_money(&self, deal: &Deal) -> Option { + self.profit_pct.map(|pct| pct / 100.0 * deal.spent) + } + + /// Whether the outcome is a closed trade the KPI may count. + pub fn is_trade(&self) -> bool { + self.profit_pct.is_some() + } +} + +/// The entry-side parameters of one variant: the strategy kind's own model, or the fact. +#[derive(Clone, Debug, PartialEq)] +pub enum EntryParams { + /// Take the entry as the report has it — for kinds without a model, and for a variant + /// whose "Entry" group is not being varied. + Fact, + MoonShot(MshotParams), +} + +/// Run one trade through the entry and the exit model. +/// +/// `ticks` is the tape the caller fetched for the deal's window, ascending by time; it must +/// reach back before `deal.buy_ms` for the entry model to have a run-up, and past +/// `deal.close_ms` for the exit to have a tail. An empty tape yields no fill. /// /// Args: -/// order_open_ms: The order's creation where a replay may start there ([`order_open_at`]). -/// buy_ms: The fill of the entry. -/// close_ms: The close. -/// margin_ms: The margin setting (`trade_replay::margin_ms`). -/// long_position_ms: The threshold the window is split by — the caller's, so every stage -/// of one row splits it the same way. +/// deal: The report row and what the caller knows about its market. +/// ticks: Prints of the window, ascending. +/// entry: Entry model parameters, or the fact. +/// exit: Sell-line parameters. +/// entry_start: The `(t_ms, price)` the real entry line was first seen at, when the order +/// archive holds it. The model then starts its order there rather than at the window's +/// first print, which is the one thing about the order's history the tape cannot tell. +pub fn simulate( + deal: &Deal, + ticks: &[Tick], + entry: &EntryParams, + exit: &ExitParams, + entry_start: Option<(i64, f64)>, +) -> Outcome { + let fill = match entry { + EntryParams::Fact => Some(Fill { + t_ms: deal.buy_ms, + price: deal.buy_price, + }), + EntryParams::MoonShot(params) => MshotEntry::new(params).fill(deal, ticks, entry_start), + }; + let Some(fill) = fill else { + return Outcome { + fill: None, + exit: None, + profit_pct: None, + }; + }; + let exit_result = ExitModel::new(exit).exit(deal, ticks, fill); + let profit_pct = match exit_result.kind { + ExitKind::OpenAtWindowEnd => None, + _ => profit_pct(deal, fill.price, exit_result.price), + }; + Outcome { + fill: Some(fill), + exit: Some(exit_result), + profit_pct, + } +} + +/// Signed result of a position in per cent of its entry, long or short. `None` for prices +/// that are not prices (non-positive or non-finite) — a degenerate placement is not a +/// break-even trade, it is no trade. +pub fn profit_pct(deal: &Deal, fill: f64, exit: f64) -> Option { + if !fill.is_finite() || fill <= 0.0 || !exit.is_finite() || exit <= 0.0 { + return None; + } + let raw = (exit - fill) / fill * 100.0; + Some(if deal.is_short { -raw } else { raw }) +} + +/// The market's price step, read off the tape: the smallest positive distance between two +/// consecutive prints of different price. Over a window of hundreds of prints the two prices +/// one step apart are all but certain to appear; a window too thin to show it answers `None`, +/// and the caller then runs without a step rather than with a guessed one. /// -/// Returns: -/// The window, or `None` when the stamps describe none. -pub fn model_window_at( - order_open_ms: Option, - buy_ms: i64, - close_ms: i64, - margin_ms: i64, - long_position_ms: i64, -) -> Option { - let with_threshold = |window: ReplayWindow| ReplayWindow { - long_position_ms, - ..window +/// The result is snapped to a power of ten times 1, 2 or 5 — the grids exchanges actually use +/// — so `f32` noise on the prints does not become a step of `0.0099999`. The cut between two +/// grid steps is their geometric mean, the nearest step in ratio rather than in difference. +pub fn infer_tick(ticks: &[Tick]) -> Option { + let mut best: Option = None; + for pair in ticks.windows(2) { + let d = (f64::from(pair[1].price) - f64::from(pair[0].price)).abs(); + if d > 0.0 && best.is_none_or(|b| d < b) { + best = Some(d); + } + } + let raw = best?; + if !raw.is_finite() || raw <= 0.0 { + return None; + } + let exp = raw.log10().floor(); + let base = 10f64.powf(exp); + let mantissa = raw / base; + let snapped = if mantissa < 2f64.sqrt() { + 1.0 + } else if mantissa < 10f64.sqrt() { + 2.0 + } else if mantissa < 50f64.sqrt() { + 5.0 + } else { + 10.0 }; - let from_creation = order_open_ms - .and_then(|open| replay_window_ms(open, close_ms, margin_ms)) - .map(with_threshold) - .filter(|w| w.close_ms - w.open_ms <= w.long_position_ms); - from_creation.or_else(|| replay_window_ms(buy_ms, close_ms, margin_ms).map(with_threshold)) + Some(snapped * base) } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs new file mode 100644 index 00000000..598df9bc --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -0,0 +1,396 @@ +//! The MoonShot entry model: a limit order at a fixed distance below the price (above, for a +//! short), walking a corridor and filled by the first print that reaches it. +//! +//! Mechanics, from the Moonbot FAQ ("Какие есть специфические параметры у стратегии MoonShot"): +//! +//! - the order stands `MShotPrice` % away from the reference price; the price may approach it +//! down to `MShotPriceMin` % — closer than that, the order is re-placed at `MShotPrice` again +//! after `MShotReplaceDelay` seconds; when the price runs away so the order is farther than +//! `MShotPrice`, it is re-placed after `MShotRaiseWait` seconds; +//! - every `MShotAdd*Delta` adds `k · delta` to BOTH bounds, the delta's sign as the report +//! carries it: the FAQ's `-10% + (-20 · 0.05) = -11%` is a coin UP 20 % on 3 h putting the +//! order 1 % deeper (checked on the live tape on 2026-09-20: a long on ROSE, up 7 % / 11 % on +//! 3 h / 24 h, had its real order deeper than the unmodified corridor by exactly `Σ k · δ`); +//! `MShotAddPriceBug` adds `k · pricebug` — deeper during exchange lag — and +//! `MShotAddDistance` scales what the FAR bound receives by `1 + distance / 100`; +//! - `MShotMinusSatoshi` keeps the order at least two price steps off the reference; +//! - `MShotUsePrice` picks the reference: the last trade, or the book's ASK / BID; +//! - `FastShotAlgo` with a non-zero `MShotRaiseWait` is the FAQ's "algorithm 2": the reference +//! is the lowest print of the last 100 ms (highest, for a short), which is what keeps the +//! order from bouncing back up on a single print. With a zero wait it is "algorithm 1", a +//! price "over the last few trades" the FAQ does not size; the model reads the last print. +//! +//! Two things the tape cannot give and the model states instead: the book (an ASK / BID +//! reference is approximated by the last buy-side / sell-side print), and the time a +//! replacement takes to reach the exchange (`latency_ms`). The latency is what makes fills +//! possible at all — a corridor that re-places instantly is never reached by a spike — and its +//! first value is a constant; calibrating it from the archive's replacement times against the +//! moments the price left the corridor is phase 3. +//! +//! The core keeps its own idea of where the order is; the exchange learns about a move +//! `latency_ms` later. A print in between fills at the OLD level. That is the one interleaving +//! the model has to get right, and it is why the state carries two levels. + +use super::{Deal, Deltas, Fill, reaches}; +use crate::feed::types::{Side, Tick}; + +/// Which price the order keeps its distance from (`MShotUsePrice`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum UsePrice { + /// The last trade — the model then matches the core exactly. + #[default] + Trade, + /// Best ask; approximated here by the last BUY-side print (a taker buy prints at the ask). + Ask, + /// Best bid; approximated here by the last SELL-side print. + Bid, +} + +impl UsePrice { + /// Parse the strategy's spelling (`Trade` / `ASK` / `BID`), case-insensitively; anything + /// else reads as `Trade`, the default of the field. + pub fn parse(s: &str) -> Self { + match s.trim().to_ascii_uppercase().as_str() { + "ASK" => Self::Ask, + "BID" => Self::Bid, + _ => Self::Trade, + } + } +} + +/// The `MShotAdd*` modifiers — per-cent added to the corridor bounds per one per cent of the +/// matching delta at the buy. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Modifiers { + pub add_1m: f64, + pub add_5m: f64, + pub add_15m: f64, + pub add_1h: f64, + pub add_3h: f64, + pub add_24h: f64, + pub add_mark: f64, + pub add_pricebug: f64, + pub add_btc_1h: f64, + pub add_btc_5m: f64, + pub add_market_1h: f64, + /// `MShotAddDistance` — per cent by which the far bound's addition exceeds the near one's. + pub distance_pct: f64, +} + +impl Modifiers { + /// The addition to the NEAR bound, in per cent, for these deltas — `Σ k · δ`, every delta + /// with its own sign, so a coin that went up gets a deeper order (the module doc has the + /// FAQ example and the live check behind the sign). + pub fn near_addition(&self, d: &Deltas) -> f64 { + self.add_1m * d.d1m + + self.add_5m * d.d5m + + self.add_15m * d.d15m + + self.add_1h * d.d1h + + self.add_3h * d.d3h + + self.add_24h * d.d24h + + self.add_mark * d.dmark + + self.add_btc_1h * d.btc1h + + self.add_btc_5m * d.btc5m + + self.add_market_1h * d.market1h + + self.add_pricebug * d.pricebug + } + + /// The addition to the FAR bound: the near one scaled by `1 + distance / 100`. + pub fn far_addition(&self, d: &Deltas) -> f64 { + self.near_addition(d) * (1.0 + self.distance_pct / 100.0) + } +} + +/// Smallest distance either bound may end up at after the modifiers, in per cent — the +/// expert-mode floor of `MShotPrice`, so a pumping coin cannot push the order ABOVE the price. +const BOUND_FLOOR_PCT: f64 = 0.02; + +/// Default replacement latency, milliseconds — see the module doc. +pub const DEFAULT_LATENCY_MS: f64 = 100.0; + +/// The seconds the FAQ's "4-second-old ASK" of `MShotSellAtLastPrice` looks back. +pub const PRE_SPIKE_LOOKBACK_MS: i64 = 4_000; + +/// The window of `FastShotAlgo`'s algorithm 2: the reference is the extreme print of the last +/// 100 ms (the FAQ: "the minimum trade over 100 ms"). +pub const FAST_ALGO_WINDOW_MS: i64 = 100; + +/// MoonShot entry parameters, in the strategy's own units (per cent, seconds). +#[derive(Clone, Debug, PartialEq)] +pub struct MshotParams { + /// `MShotPrice` — the far bound, per cent below the reference (above, for a short). + pub price_pct: f64, + /// `MShotPriceMin` — the near bound. + pub price_min_pct: f64, + pub use_price: UsePrice, + /// `MShotRaiseWait`, seconds — before re-placing after the price RAN AWAY. + pub raise_wait_s: f64, + /// `MShotReplaceDelay`, seconds — before re-placing after the price CAME TOO CLOSE. + pub replace_delay_s: f64, + /// `MShotMinusSatoshi`. + pub minus_satoshi: bool, + /// `FastShotAlgo` — see the module doc; only algorithm 2 (with a non-zero raise wait) + /// changes the reference. + pub fast_algo: bool, + pub modifiers: Modifiers, + /// Model parameter, not a strategy field: how long a replacement takes to reach the book. + pub latency_ms: f64, +} + +impl Default for MshotParams { + /// A plain 1 % / 0.5 % corridor with no waits and no modifiers — the shape every real + /// strategy overrides, kept sane so a missing field never yields a zero-width corridor. + fn default() -> Self { + Self { + price_pct: 1.0, + price_min_pct: 0.5, + use_price: UsePrice::Trade, + raise_wait_s: 0.0, + replace_delay_s: 0.0, + minus_satoshi: false, + fast_algo: false, + modifiers: Modifiers::default(), + latency_ms: DEFAULT_LATENCY_MS, + } + } +} + +impl MshotParams { + /// The effective `(near, far)` bounds in per cent for a deal's deltas, floored and ordered. + pub fn bounds_pct(&self, deltas: &Deltas) -> (f64, f64) { + let near = (self.price_min_pct + self.modifiers.near_addition(deltas)).max(BOUND_FLOOR_PCT); + let far = (self.price_pct + self.modifiers.far_addition(deltas)).max(near); + (near, far) + } +} + +/// Which way the price left the corridor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Breach { + /// The price came closer than the near bound. + Approach, + /// The price ran farther than the far bound. + Retreat, +} + +/// The MoonShot entry model over one parameter set. +pub struct MshotEntry<'a> { + params: &'a MshotParams, +} + +impl<'a> MshotEntry<'a> { + pub fn new(params: &'a MshotParams) -> Self { + Self { params } + } + + /// Where the order would stand for a reference price: the far bound away, kept two steps + /// off the reference when `MShotMinusSatoshi` says so, snapped to the price grid AWAY from + /// the reference (a limit is placed on the grid, and the core rounds toward safety). + fn place(&self, reference: f64, far_pct: f64, deal: &Deal) -> f64 { + let distance = reference * far_pct / 100.0; + let mut level = if deal.is_long() { + reference - distance + } else { + reference + distance + }; + if let Some(tick) = deal.tick.filter(|t| *t > 0.0) { + if self.params.minus_satoshi { + let keep_off = 2.0 * tick; + level = if deal.is_long() { + level.min(reference - keep_off) + } else { + level.max(reference + keep_off) + }; + } + let steps = level / tick; + level = if deal.is_long() { + steps.floor() * tick + } else { + steps.ceil() * tick + }; + } + level + } + + /// Distance from the reference to the level, per cent, positive when the level is on the + /// order's own side of the price (below for a long) and negative when the price has + /// crossed it. + fn distance_pct(reference: f64, level: f64, deal: &Deal) -> f64 { + if reference <= 0.0 { + return 0.0; + } + let signed = if deal.is_long() { + reference - level + } else { + level - reference + }; + signed / reference * 100.0 + } + + /// The tape replay — see the module doc for the two-level bookkeeping. + pub(super) fn run( + &self, + deal: &Deal, + ticks: &[Tick], + start: Option<(i64, f64)>, + ) -> Option { + if ticks.is_empty() { + return None; + } + let (near_pct, far_pct) = self.params.bounds_pct(&deal.deltas); + let raise_wait_ms = (self.params.raise_wait_s * 1000.0).max(0.0); + let replace_delay_ms = (self.params.replace_delay_s * 1000.0).max(0.0); + let latency_ms = self.params.latency_ms.max(0.0); + + let mut reference = Reference::new( + self.params.use_price, + self.params.fast_algo && raise_wait_ms > 0.0, + deal.is_long(), + ); + + // Where the tape starts for the order: at the archive's first point, or at the first + // print. Prints before the start only feed the reference. + let start_ms = start.map(|(t, _)| t); + let mut index = 0; + if let Some(start_ms) = start_ms { + while index < ticks.len() && (ticks[index].time_ms as i64) < start_ms { + reference.observe(&ticks[index]); + index += 1; + } + } + // The exchange's level (what fills) and the core's (what the corridor is measured + // against); `pending` is a move the core made that the exchange has not seen yet. + let (mut exch_level, mut core_level) = match start { + Some((_, price)) if price > 0.0 => (price, price), + _ => { + // No archive: the order is placed off the first print, which then cannot fill + // it (it is the reference itself). + let first = ticks.get(index)?; + reference.observe(first); + index += 1; + let level = self.place(reference.price()?, far_pct, deal); + (level, level) + } + }; + let mut pending: Option<(i64, f64)> = None; + let mut breach: Option<(Breach, i64)> = None; + + for tick in &ticks[index..] { + let t_ms = tick.time_ms as i64; + let price = f64::from(tick.price); + if !price.is_finite() || price <= 0.0 { + continue; + } + if let Some((_, level)) = pending.filter(|(apply_at, _)| t_ms >= *apply_at) { + exch_level = level; + pending = None; + } + if reaches(price, exch_level, deal.is_long()) { + return Some(Fill { + t_ms, + price: exch_level, + }); + } + reference.observe(tick); + let Some(reference) = reference.price() else { + continue; + }; + let distance = Self::distance_pct(reference, core_level, deal); + let now = if distance < near_pct { + Some(Breach::Approach) + } else if distance > far_pct { + Some(Breach::Retreat) + } else { + None + }; + match now { + None => breach = None, + Some(kind) => { + let since = match breach { + Some((seen, since)) if seen == kind => since, + _ => { + breach = Some((kind, t_ms)); + t_ms + } + }; + let wait_ms = match kind { + Breach::Approach => replace_delay_ms, + Breach::Retreat => raise_wait_ms, + }; + if (t_ms - since) as f64 >= wait_ms { + core_level = self.place(reference, far_pct, deal); + pending = Some((t_ms + latency_ms as i64, core_level)); + breach = None; + } + } + } + } + None + } +} + +/// The reference price the corridor is measured from, as the prints go by. +/// +/// The last print of the wanted side (`MShotUsePrice`), falling back to the last print of any +/// side until one of that side has been seen; under algorithm 2 of `FastShotAlgo`, the extreme +/// of the wanted side's prints inside the last [`FAST_ALGO_WINDOW_MS`]. +struct Reference { + wanted_side: Option, + fast: bool, + is_long: bool, + last_any: Option, + last_side: Option, + /// `(t_ms, price)` of the wanted side's prints inside the fast window, oldest first. + recent: std::collections::VecDeque<(i64, f64)>, +} + +impl Reference { + fn new(use_price: UsePrice, fast: bool, is_long: bool) -> Self { + Self { + wanted_side: match use_price { + UsePrice::Trade => None, + UsePrice::Ask => Some(Side::Buy), + UsePrice::Bid => Some(Side::Sell), + }, + fast, + is_long, + last_any: None, + last_side: None, + recent: std::collections::VecDeque::new(), + } + } + + fn observe(&mut self, tick: &Tick) { + let price = f64::from(tick.price); + if !price.is_finite() || price <= 0.0 { + return; + } + self.last_any = Some(price); + if self.wanted_side.is_none_or(|s| s == tick.side) { + self.last_side = Some(price); + if self.fast { + let t_ms = tick.time_ms as i64; + self.recent.push_back((t_ms, price)); + while self + .recent + .front() + .is_some_and(|(t, _)| t_ms - *t > FAST_ALGO_WINDOW_MS) + { + self.recent.pop_front(); + } + } + } + } + + fn price(&self) -> Option { + if self.fast && !self.recent.is_empty() { + let prices = self.recent.iter().map(|(_, p)| *p); + return if self.is_long { + prices.reduce(f64::min) + } else { + prices.reduce(f64::max) + }; + } + self.last_side.or(self.last_any) + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs new file mode 100644 index 00000000..0feb11ee --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -0,0 +1,324 @@ +//! The parameter descriptor of the axis — the ONE place a strategy field of the Entry/Exit +//! grid is declared. The UI grid, the "now" column, the search grid and the save dialog all +//! derive from [`TICK_PARAMS`]; the model structs are built from a strategy's values through +//! [`mshot_params`] and [`exit_params`] here, so a field name is spelled once. +//! +//! Field names are the `strategies.sqlite` keys (verified against the live file, 2026-09-20). +//! A key absent from a strategy's `raw_json` is at its schema default — the wire omits fields at +//! default — which the caller passes in from the live schema; a key absent from both falls to +//! the model's own default. + +use std::collections::HashMap; + +use super::exit::ExitParams; +use super::mshot::{Modifiers, MshotParams, UsePrice}; + +/// Which group of the grid a parameter belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParamGroup { + /// The entry model — available only for kinds that have one. + Entry, + /// The sell line — every kind. + Exit, +} + +/// How a parameter is typed and, for the search, which values it may take. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ParamKind { + /// A number; `grid` is the search's discrete candidate set (§5.1 of the spec — a proposal + /// to narrow to practice). + Num { grid: &'static [f64] }, + /// `YES` / `NO`. + Bool, + /// One of a fixed spelling set. + Enum(&'static [&'static str]), +} + +/// One parameter of the axis. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TickParam { + /// The strategy field name, as stored and as shown in the grid. + pub key: &'static str, + pub group: ParamGroup, + pub kind: ParamKind, + /// Strategy kinds whose grid shows this parameter; empty means every kind. + pub kinds: &'static [&'static str], +} + +const MSHOT: &[&str] = &["MoonShot"]; +const ANY: &[&str] = &[]; + +const GRID_PRICE: &[f64] = &[ + 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 6.5, + 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, +]; +const GRID_PRICE_MIN: &[f64] = &[ + 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.25, 1.5, + 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, +]; +const GRID_WAIT_S: &[f64] = &[0.0, 0.1, 0.3, 0.5, 1.0, 2.0, 5.0]; +const GRID_ADJUST: &[f64] = &[ + -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, + 1.6, 1.8, 2.0, +]; +const GRID_ADD: &[f64] = &[ + 0.0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.12, 0.14, 0.16, 0.18, 0.2, +]; +const GRID_DISTANCE: &[f64] = &[0.0, 25.0, 50.0, 100.0, 200.0]; +const GRID_SELL_PRICE: &[f64] = &[ + 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, + 5.0, +]; +const GRID_SELL_DELAY_MS: &[f64] = &[0.0, 100.0, 250.0, 500.0, 1000.0]; + +/// Every parameter of the axis, grid order: the Entry group first, then Exit. +pub const TICK_PARAMS: &[TickParam] = &[ + TickParam { + key: "MShotPrice", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_PRICE }, + kinds: MSHOT, + }, + TickParam { + key: "MShotPriceMin", + group: ParamGroup::Entry, + kind: ParamKind::Num { + grid: GRID_PRICE_MIN, + }, + kinds: MSHOT, + }, + TickParam { + key: "MShotUsePrice", + group: ParamGroup::Entry, + kind: ParamKind::Enum(&["Trade", "ASK", "BID"]), + kinds: MSHOT, + }, + TickParam { + key: "MShotRaiseWait", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_WAIT_S }, + kinds: MSHOT, + }, + TickParam { + key: "MShotReplaceDelay", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_WAIT_S }, + kinds: MSHOT, + }, + TickParam { + key: "MShotMinusSatoshi", + group: ParamGroup::Entry, + kind: ParamKind::Bool, + kinds: MSHOT, + }, + TickParam { + key: "FastShotAlgo", + group: ParamGroup::Entry, + kind: ParamKind::Bool, + kinds: MSHOT, + }, + TickParam { + key: "MShotAddHourlyDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAdd3hDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAdd15minDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAdd5minDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAdd1minDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAdd24hDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAddMarkDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAddMarketDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAddBTCDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAddBTC5mDelta", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAddPriceBug", + group: ParamGroup::Entry, + kind: ParamKind::Num { grid: GRID_ADD }, + kinds: MSHOT, + }, + TickParam { + key: "MShotAddDistance", + group: ParamGroup::Entry, + kind: ParamKind::Num { + grid: GRID_DISTANCE, + }, + kinds: MSHOT, + }, + TickParam { + key: "SellPrice", + group: ParamGroup::Exit, + kind: ParamKind::Num { + grid: GRID_SELL_PRICE, + }, + kinds: ANY, + }, + TickParam { + key: "MShotSellAtLastPrice", + group: ParamGroup::Exit, + kind: ParamKind::Bool, + kinds: MSHOT, + }, + TickParam { + key: "MShotSellPriceAdjust", + group: ParamGroup::Exit, + kind: ParamKind::Num { grid: GRID_ADJUST }, + kinds: MSHOT, + }, + TickParam { + key: "SellDelay", + group: ParamGroup::Exit, + kind: ParamKind::Num { + grid: GRID_SELL_DELAY_MS, + }, + kinds: ANY, + }, +]; + +/// The parameters of one group that a kind's grid shows. +pub fn params_for<'k>( + group: ParamGroup, + kind: &'k str, +) -> impl Iterator + 'k { + TICK_PARAMS + .iter() + .filter(move |p| p.group == group && (p.kinds.is_empty() || p.kinds.contains(&kind))) +} + +/// The field names of [`TICK_PARAMS`], for a `strategy_current_values` read. +pub fn param_keys() -> Vec { + TICK_PARAMS.iter().map(|p| p.key.to_string()).collect() +} + +/// Strategy values as `strategy_current_values` hands them (strings, `YES`/`NO` booleans) plus +/// the schema defaults for the keys the strategy left at default (lowercase key → number). +pub struct StrategyValues<'a> { + pub values: &'a HashMap, + pub defaults: &'a HashMap, +} + +impl StrategyValues<'_> { + /// A numeric field: the strategy's value, else the schema default, else `fallback`. + pub fn num(&self, key: &str, fallback: f64) -> f64 { + self.values + .get(key) + .and_then(|s| parse_num(s)) + .or_else(|| self.defaults.get(&key.to_ascii_lowercase()).copied()) + .unwrap_or(fallback) + } + + /// A boolean field (`YES`/`NO`, `true`/`false`, `1`/`0`); absent → the schema default read + /// as a number, else `fallback`. + pub fn bool(&self, key: &str, fallback: bool) -> bool { + match self.values.get(key).map(|s| s.trim().to_ascii_uppercase()) { + Some(s) if s == "YES" || s == "TRUE" || s == "1" => true, + Some(s) if s == "NO" || s == "FALSE" || s == "0" => false, + _ => self + .defaults + .get(&key.to_ascii_lowercase()) + .map(|d| *d != 0.0) + .unwrap_or(fallback), + } + } + + /// A string field, or `fallback` when absent. + pub fn text<'b>(&'b self, key: &str, fallback: &'b str) -> &'b str { + self.values.get(key).map(String::as_str).unwrap_or(fallback) + } +} + +/// Parse a strategy number: `1.5`, `1,5`, `1.5%`. +fn parse_num(s: &str) -> Option { + s.trim() + .trim_end_matches('%') + .replace(',', ".") + .parse::() + .ok() + .filter(|v| v.is_finite()) +} + +/// MoonShot entry parameters out of a strategy's values; `latency_ms` is the model's own. +pub fn mshot_params(v: &StrategyValues<'_>, latency_ms: f64) -> MshotParams { + let base = MshotParams::default(); + MshotParams { + price_pct: v.num("MShotPrice", base.price_pct), + price_min_pct: v.num("MShotPriceMin", base.price_min_pct), + use_price: UsePrice::parse(v.text("MShotUsePrice", "Trade")), + raise_wait_s: v.num("MShotRaiseWait", base.raise_wait_s), + replace_delay_s: v.num("MShotReplaceDelay", base.replace_delay_s), + minus_satoshi: v.bool("MShotMinusSatoshi", base.minus_satoshi), + fast_algo: v.bool("FastShotAlgo", base.fast_algo), + modifiers: Modifiers { + add_1m: v.num("MShotAdd1minDelta", 0.0), + add_5m: v.num("MShotAdd5minDelta", 0.0), + add_15m: v.num("MShotAdd15minDelta", 0.0), + add_1h: v.num("MShotAddHourlyDelta", 0.0), + add_3h: v.num("MShotAdd3hDelta", 0.0), + add_24h: v.num("MShotAdd24hDelta", 0.0), + add_mark: v.num("MShotAddMarkDelta", 0.0), + add_pricebug: v.num("MShotAddPriceBug", 0.0), + add_btc_1h: v.num("MShotAddBTCDelta", 0.0), + add_btc_5m: v.num("MShotAddBTC5mDelta", 0.0), + add_market_1h: v.num("MShotAddMarketDelta", 0.0), + distance_pct: v.num("MShotAddDistance", 0.0), + }, + latency_ms, + } +} + +/// Sell-line parameters out of a strategy's values. +pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { + let base = ExitParams::default(); + ExitParams { + sell_price_pct: v.num("SellPrice", base.sell_price_pct), + sell_at_last_price: v.bool("MShotSellAtLastPrice", base.sell_at_last_price), + sell_price_adjust_pct: v.num("MShotSellPriceAdjust", base.sell_price_adjust_pct), + sell_delay_ms: v.num("SellDelay", base.sell_delay_ms), + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs new file mode 100644 index 00000000..c88cb446 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -0,0 +1,803 @@ +//! The model on synthetic tapes: every rule of the spec's §8, one print at a time. + +use std::collections::HashMap; + +use super::exit::pre_spike_price; +use super::mshot::{DEFAULT_LATENCY_MS, Modifiers, PRE_SPIKE_LOOKBACK_MS}; +use super::params::{StrategyValues, exit_params, mshot_params, param_keys, params_for}; +use super::verify::share; +use super::*; +use crate::feed::types::Side; + +/// The ignored run over a live data root. +mod real_data; + +fn tick(t_ms: i64, price: f64, side: Side) -> Tick { + Tick { + time_ms: t_ms as f64, + price: price as f32, + qty: 1.0, + side, + } +} + +/// A tape of buy-side prints at `(t_ms, price)`. +fn tape(points: &[(i64, f64)]) -> Vec { + points.iter().map(|&(t, p)| tick(t, p, Side::Buy)).collect() +} + +fn deal() -> Deal { + Deal { + report_uid: 1, + core_uid: 7, + strategy_id: 42, + kind: "MoonShot".into(), + coin: "ACE".into(), + buy_ms: 10_000, + close_ms: 20_000, + buy_price: 99.0, + sell_price: 100.0, + spent: 1_000.0, + is_short: false, + sell_reason: "Sell Price".into(), + deltas: Deltas::default(), + tick: None, + } +} + +fn short_deal() -> Deal { + Deal { + is_short: true, + buy_price: 101.0, + sell_price: 100.0, + ..deal() + } +} + +/// A 1 % / 0.5 % corridor with no waits and a 100 ms latency. +fn mshot() -> MshotParams { + MshotParams::default() +} + +fn fill_of(deal: &Deal, ticks: &[Tick], params: &MshotParams) -> Option { + MshotEntry::new(params).fill(deal, ticks, None) +} + +// ---- entry: the level and the fill ------------------------------------------------------- + +#[test] +fn a_spike_exactly_to_the_level_fills_at_the_level() { + // Placed off the first print at 100: level 99. + let ticks = tape(&[(0, 100.0), (500, 99.5), (1_000, 99.0)]); + let fill = fill_of(&deal(), &ticks, &mshot()).expect("filled"); + assert_eq!(fill.t_ms, 1_000); + assert!((fill.price - 99.0).abs() < 1e-9); +} + +#[test] +fn a_spike_one_step_short_does_not_fill() { + let ticks = tape(&[(0, 100.0), (500, 99.5), (1_000, 99.01)]); + assert_eq!(fill_of(&deal(), &ticks, &mshot()), None); +} + +#[test] +fn the_fill_price_is_the_level_not_the_print() { + let ticks = tape(&[(0, 100.0), (1_000, 97.0)]); + let fill = fill_of(&deal(), &ticks, &mshot()).expect("filled"); + assert!( + (fill.price - 99.0).abs() < 1e-9, + "a limit fills at its own price" + ); +} + +#[test] +fn an_empty_tape_never_fills() { + assert_eq!(fill_of(&deal(), &[], &mshot()), None); +} + +#[test] +fn the_archived_start_places_the_order_where_the_core_did() { + // The tape says 100 at t=0, but the archive says the order stood at 98.5 from t=200. + let ticks = tape(&[(0, 100.0), (300, 99.0), (600, 98.5)]); + let fill = MshotEntry::new(&mshot()) + .fill(&deal(), &ticks, Some((200, 98.5))) + .expect("filled"); + assert_eq!(fill.t_ms, 600); + assert!((fill.price - 98.5).abs() < 1e-9); +} + +// ---- entry: the corridor, the waits and the latency race ---------------------------------- + +#[test] +fn approaching_inside_price_min_moves_the_order_after_the_latency() { + // Level 99 off 100. At t=1000 the price is 99.6: distance 0.6 % > 0.5 %, inside the + // corridor. At t=2000 it is 99.3: distance 0.3 % < PriceMin → replace at once (delay 0), + // effective at t=2100. A print at 99.0 at t=2050 still fills the OLD level; the same print + // at t=2200 does not — the order has moved to 99.3 · 0.99. + let raced = tape(&[(0, 100.0), (1_000, 99.6), (2_000, 99.3), (2_050, 99.0)]); + let fill = fill_of(&deal(), &raced, &mshot()).expect("filled at the old level"); + assert_eq!(fill.t_ms, 2_050); + assert!((fill.price - 99.0).abs() < 1e-9); + + let missed = tape(&[(0, 100.0), (1_000, 99.6), (2_000, 99.3), (2_200, 99.0)]); + assert_eq!( + fill_of(&deal(), &missed, &mshot()), + None, + "the order had moved" + ); +} + +#[test] +fn without_latency_the_corridor_is_never_reached_by_a_step_down() { + let params = MshotParams { + latency_ms: 0.0, + ..mshot() + }; + let ticks = tape(&[(0, 100.0), (1_000, 99.3), (1_000, 99.0)]); + assert_eq!(fill_of(&deal(), &ticks, ¶ms), None); +} + +#[test] +fn replace_delay_holds_the_order_while_the_approach_is_shorter_than_it() { + let params = MshotParams { + replace_delay_s: 1.0, + ..mshot() + }; + // Approach at t=1000 (99.3), still there at t=1500: 500 ms < 1 s, the order is still at + // 99 and a print at 99.0 fills it. + let ticks = tape(&[(0, 100.0), (1_000, 99.3), (1_500, 99.3), (1_600, 99.0)]); + let fill = fill_of(&deal(), &ticks, ¶ms).expect("filled"); + assert_eq!(fill.t_ms, 1_600); + // Held for 1 s: replaced at t=2000, effective at 2100, and the print at 2200 misses. + let ticks = tape(&[(0, 100.0), (1_000, 99.3), (2_000, 99.3), (2_200, 99.0)]); + assert_eq!(fill_of(&deal(), &ticks, ¶ms), None); +} + +#[test] +fn raise_wait_follows_the_price_up_only_after_the_wait() { + let params = MshotParams { + raise_wait_s: 1.0, + ..mshot() + }; + // Level 99 off 100. Price runs to 102 (distance 2.9 % > 1 %) at t=1000; before the wait + // expires a spike to 99 at t=1500 fills the old order. + let ticks = tape(&[(0, 100.0), (1_000, 102.0), (1_500, 99.0)]); + assert!(fill_of(&deal(), &ticks, ¶ms).is_some()); + // After the wait (t=2000) the order moves to 102 · 0.99 = 100.98 (effective 2100); a print + // at 100.9 at t=2200 fills the NEW level. + let ticks = tape(&[(0, 100.0), (1_000, 102.0), (2_000, 102.0), (2_200, 100.9)]); + let fill = fill_of(&deal(), &ticks, ¶ms).expect("filled at the new level"); + assert!((fill.price - 100.98).abs() < 1e-9, "{}", fill.price); +} + +#[test] +fn leaving_and_re_entering_the_corridor_resets_the_wait() { + let params = MshotParams { + raise_wait_s: 1.0, + ..mshot() + }; + // Out at t=1000, back inside at t=1500 (100.5: distance 1.49 %… still out). Use 99.8: + // distance 0.8 %, inside. Out again at t=1800; at t=2500 only 700 ms have passed since + // the SECOND breach, so the order has not moved and 99.0 still fills. + let ticks = tape(&[ + (0, 100.0), + (1_000, 102.0), + (1_500, 99.8), + (1_800, 102.0), + (2_500, 102.0), + (2_600, 99.0), + ]); + let fill = fill_of(&deal(), &ticks, ¶ms).expect("filled"); + assert!((fill.price - 99.0).abs() < 1e-9); +} + +// ---- entry: modifiers, the price grid, the reference -------------------------------------- + +#[test] +fn delta_modifiers_deepen_a_pumping_coin_and_lift_a_falling_one() { + // FAQ: MShotAdd3hDelta 0.05, MShotPrice 10, a coin up 20 % on 3 h -> -10 + (-20 * 0.05) = + // -11, i.e. one per cent deeper. + let params = MshotParams { + price_pct: 10.0, + price_min_pct: 7.0, + modifiers: Modifiers { + add_3h: 0.05, + ..Modifiers::default() + }, + ..mshot() + }; + let pumping = Deltas { + d3h: 20.0, + ..Deltas::default() + }; + let (near, far) = params.bounds_pct(&pumping); + assert!( + (near - 8.0).abs() < 1e-9 && (far - 11.0).abs() < 1e-9, + "{near} {far}" + ); + let falling = Deltas { + d3h: -20.0, + ..Deltas::default() + }; + let (near, far) = params.bounds_pct(&falling); + assert!( + (near - 6.0).abs() < 1e-9 && (far - 9.0).abs() < 1e-9, + "{near} {far}" + ); +} + +#[test] +fn add_distance_scales_only_the_far_bound() { + let params = MshotParams { + price_pct: 10.0, + price_min_pct: 7.0, + modifiers: Modifiers { + add_1h: 0.05, + distance_pct: 100.0, + ..Modifiers::default() + }, + ..mshot() + }; + let d = Deltas { + d1h: 20.0, + ..Deltas::default() + }; + let (near, far) = params.bounds_pct(&d); + assert!((near - 8.0).abs() < 1e-9, "{near}"); + assert!( + (far - 12.0).abs() < 1e-9, + "far gets 1 · (1 + 100/100) = 2: {far}" + ); +} + +#[test] +fn price_bug_deepens_the_order() { + let params = MshotParams { + modifiers: Modifiers { + add_pricebug: 0.2, + ..Modifiers::default() + }, + ..mshot() + }; + let d = Deltas { + pricebug: 2.0, + ..Deltas::default() + }; + let (_, far) = params.bounds_pct(&d); + assert!((far - 1.4).abs() < 1e-9, "{far}"); +} + +#[test] +fn a_dump_cannot_push_the_bounds_below_the_floor_or_cross_them() { + let params = MshotParams { + price_pct: 1.0, + price_min_pct: 0.5, + modifiers: Modifiers { + add_1h: 0.1, + ..Modifiers::default() + }, + ..mshot() + }; + let d = Deltas { + d1h: -50.0, + ..Deltas::default() + }; + let (near, far) = params.bounds_pct(&d); + assert!(near > 0.0 && far >= near); +} + +#[test] +fn the_level_snaps_to_the_price_grid_away_from_the_price() { + let mut d = deal(); + d.tick = Some(0.05); + // 100 · 0.99 = 99.0 exactly; use 100.07 → 99.0693 → floored to 99.05. + let ticks = tape(&[(0, 100.07), (1_000, 99.05)]); + let fill = fill_of(&d, &ticks, &mshot()).expect("filled"); + assert!((fill.price - 99.05).abs() < 1e-9, "{}", fill.price); +} + +#[test] +fn minus_satoshi_keeps_two_steps_off_the_reference() { + let mut d = deal(); + d.tick = Some(0.5); + let params = MshotParams { + price_pct: 0.1, + price_min_pct: 0.05, + minus_satoshi: true, + ..mshot() + }; + // 0.1 % of 100 is 0.1, under two steps (1.0): the order goes to 99.0. + let ticks = tape(&[(0, 100.0), (1_000, 99.0)]); + let fill = fill_of(&d, &ticks, ¶ms).expect("filled"); + assert!((fill.price - 99.0).abs() < 1e-9, "{}", fill.price); +} + +#[test] +fn an_ask_reference_follows_buy_side_prints_only() { + let params = MshotParams { + use_price: UsePrice::Ask, + ..mshot() + }; + // A BUY at 100 is the reference: level 99. A SELL at 101 must not move an ASK-referenced + // order (the distance to 99 is still 1 % off the buy print), so 99.9 does not fill. + let ticks = vec![ + tick(0, 100.0, Side::Buy), + tick(1_000, 101.0, Side::Sell), + tick(1_200, 99.9, Side::Sell), + ]; + assert_eq!( + fill_of(&deal(), &ticks, ¶ms), + None, + "sell prints do not move an ASK order" + ); + // The same 101 as a BUY is a retreat (1.98 % > 1 %): the order moves to 99.99 at once, + // effective +100 ms, and the print at 99.9 fills the new level. + let ticks = vec![ + tick(0, 100.0, Side::Buy), + tick(1_000, 101.0, Side::Buy), + tick(1_200, 99.9, Side::Sell), + ]; + let fill = fill_of(&deal(), &ticks, ¶ms).expect("moved off the buy print, then filled"); + assert!((fill.price - 99.99).abs() < 1e-9, "{}", fill.price); +} + +#[test] +fn fast_algo_measures_the_corridor_from_the_extreme_print_of_the_last_100_ms() { + // An 80 ms replace delay and no latency, so the move lands between prints and the two + // references can be told apart. + let params = MshotParams { + fast_algo: true, + raise_wait_s: 30.0, + replace_delay_s: 0.08, + latency_ms: 0.0, + ..mshot() + }; + let plain = MshotParams { + fast_algo: false, + ..params.clone() + }; + // Level 99 off 100. A dip to 99.4 at t=1000 (0.40 % < 0.5 %: an approach) followed by 99.6 + // prints at t=1050 and t=1090. The plain reference is the last print, 99.6 → 0.60 %, inside + // the corridor: the approach is forgotten and 99.0 at t=1100 fills. The fast reference is + // the lowest print of the last 100 ms — still the 99.4 at t=1090 — so the approach has + // held for 90 ms ≥ 80 ms and the order moves off 99 before the 99.0 arrives. + let dip = tape(&[ + (0, 100.0), + (1_000, 99.4), + (1_050, 99.6), + (1_090, 99.6), + (1_100, 99.0), + ]); + assert!( + fill_of(&deal(), &dip, &plain).is_some(), + "plain: still at 99" + ); + assert_eq!( + fill_of(&deal(), &dip, ¶ms), + None, + "fast: moved off the 99.4" + ); + // The 99.4 falls out of the window after 100 ms: at t=1150 the reference is 99.6 again, the + // approach is forgotten, and 99.0 fills. + let back = tape(&[(0, 100.0), (1_000, 99.4), (1_150, 99.6), (1_200, 99.0)]); + let fill = fill_of(&deal(), &back, ¶ms).expect("still at 99 after the dip aged out"); + assert!((fill.price - 99.0).abs() < 1e-9); + // Without a raise wait the fast algo is algorithm 1, which the model reads as the plain + // last print. + let algo1 = MshotParams { + raise_wait_s: 0.0, + ..params + }; + assert!(fill_of(&deal(), &dip, &algo1).is_some()); +} + +#[test] +fn use_price_parses_the_strategy_spellings() { + assert_eq!(UsePrice::parse("Trade"), UsePrice::Trade); + assert_eq!(UsePrice::parse("ask"), UsePrice::Ask); + assert_eq!(UsePrice::parse(" BID "), UsePrice::Bid); + assert_eq!(UsePrice::parse("whatever"), UsePrice::Trade); +} + +// ---- short: the mirror ------------------------------------------------------------------- + +#[test] +fn a_short_places_above_and_fills_on_a_spike_up() { + let ticks = tape(&[(0, 100.0), (1_000, 101.0)]); + let fill = fill_of(&short_deal(), &ticks, &mshot()).expect("filled"); + assert!((fill.price - 101.0).abs() < 1e-9); + let ticks = tape(&[(0, 100.0), (1_000, 100.99)]); + assert_eq!(fill_of(&short_deal(), &ticks, &mshot()), None); +} + +#[test] +fn a_short_approach_is_a_rise_and_moves_the_order_up() { + // Level 101 off 100; the price rises to 100.7 (distance 0.3 % < 0.5 %) → the order moves to + // 101.707 after 100 ms; a print at 101.0 at +200 ms no longer fills. + let ticks = tape(&[(0, 100.0), (1_000, 100.7), (1_200, 101.0)]); + assert_eq!(fill_of(&short_deal(), &ticks, &mshot()), None); +} + +// ---- exit: the take and the fallback ----------------------------------------------------- + +#[test] +fn the_take_is_sell_price_above_the_fill() { + let exit = ExitParams { + sell_price_pct: 1.0, + ..ExitParams::default() + }; + let fill = Fill { + t_ms: 1_000, + price: 99.0, + }; + let ticks = tape(&[(0, 100.0), (1_000, 99.0), (2_000, 99.98), (3_000, 99.99)]); + let out = ExitModel::new(&exit).exit(&deal(), &ticks, fill); + assert_eq!(out.kind, ExitKind::Take); + assert_eq!(out.t_ms, 3_000); + assert!((out.price - 99.99).abs() < 1e-9); +} + +#[test] +fn sell_at_last_price_lifts_the_take_to_the_pre_spike_price_less_the_adjustment() { + // Pre-spike print (≥ 4 s before the fill): 100 at t=0; adjust 1 % → 99.0 … which is + // below the plain take 99.99, so the plain one wins. With adjust 0 the pre-spike 100 + // wins over 99.99. + let ticks = tape(&[(0, 100.0), (5_000, 99.0), (6_000, 99.995), (7_000, 100.0)]); + let fill = Fill { + t_ms: 5_000, + price: 99.0, + }; + let plain = ExitParams { + sell_price_pct: 1.0, + sell_at_last_price: true, + sell_price_adjust_pct: 1.0, + ..ExitParams::default() + }; + let model = ExitModel::new(&plain); + assert!((model.take_level(&deal(), &ticks, fill) - 99.99).abs() < 1e-9); + let lifted = ExitParams { + sell_price_adjust_pct: 0.0, + ..plain + }; + let model = ExitModel::new(&lifted); + assert!((model.take_level(&deal(), &ticks, fill) - 100.0).abs() < 1e-9); + let out = model.exit(&deal(), &ticks, fill); + assert_eq!((out.kind, out.t_ms), (ExitKind::Take, 7_000)); +} + +#[test] +fn the_pre_spike_price_is_the_last_print_at_least_four_seconds_back() { + let ticks = tape(&[(0, 100.0), (900, 100.5), (1_000, 101.0), (4_000, 95.0)]); + let at = 5_000; + assert_eq!(pre_spike_price(&ticks, at), Some(101.0)); + assert_eq!(pre_spike_price(&ticks, PRE_SPIKE_LOOKBACK_MS - 1), None); +} + +#[test] +fn a_take_the_tape_never_reaches_falls_back_to_the_fact() { + let fill = Fill { + t_ms: 10_000, + price: 99.0, + }; + let ticks = tape(&[ + (9_000, 100.0), + (10_000, 99.0), + (20_000, 99.5), + (25_000, 99.5), + ]); + let out = ExitModel::new(&ExitParams::default()).exit(&deal(), &ticks, fill); + assert_eq!(out.kind, ExitKind::Fact); + assert_eq!(out.t_ms, 20_000); + assert!((out.price - 100.0).abs() < 1e-9); +} + +#[test] +fn a_fill_after_the_fact_closed_is_open_at_the_window_end() { + let fill = Fill { + t_ms: 21_000, + price: 99.0, + }; + let ticks = tape(&[(9_000, 100.0), (21_000, 99.0), (25_000, 99.5)]); + let out = ExitModel::new(&ExitParams::default()).exit(&deal(), &ticks, fill); + assert_eq!(out.kind, ExitKind::OpenAtWindowEnd); +} + +#[test] +fn sell_delay_arms_the_take_late() { + let exit = ExitParams { + sell_delay_ms: 500.0, + ..ExitParams::default() + }; + let fill = Fill { + t_ms: 1_000, + price: 99.0, + }; + // 100.5 at +300 ms is inside the delay; 100.2 at +700 ms is the exit. + let ticks = tape(&[ + (1_000, 99.0), + (1_300, 100.5), + (1_700, 100.2), + (20_000, 99.0), + ]); + let out = ExitModel::new(&exit).exit(&deal(), &ticks, fill); + assert_eq!((out.kind, out.t_ms), (ExitKind::Take, 1_700)); +} + +#[test] +fn a_short_take_is_below_the_fill() { + let fill = Fill { + t_ms: 1_000, + price: 101.0, + }; + let ticks = tape(&[(1_000, 101.0), (2_000, 100.0), (3_000, 99.9)]); + let out = ExitModel::new(&ExitParams::default()).exit(&short_deal(), &ticks, fill); + assert_eq!((out.kind, out.t_ms), (ExitKind::Take, 3_000)); + assert!((out.price - 99.99).abs() < 1e-9); +} + +// ---- simulate: the whole trade ----------------------------------------------------------- + +#[test] +fn simulate_chains_entry_and_exit_and_signs_the_result() { + let ticks = tape(&[(0, 100.0), (1_000, 99.0), (2_000, 100.0)]); + let out = simulate( + &deal(), + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + ); + let pct = out.profit_pct.expect("a trade"); + assert!((pct - 1.0).abs() < 1e-9, "{pct}"); + assert!((out.profit_money(&deal()).unwrap() - 10.0).abs() < 1e-9); + + let short = simulate( + &short_deal(), + &tape(&[(0, 100.0), (1_000, 101.0), (2_000, 99.9)]), + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + ); + assert!((short.profit_pct.unwrap() - 1.0).abs() < 1e-9); +} + +#[test] +fn a_degenerate_price_is_no_trade_not_a_break_even_one() { + assert_eq!(profit_pct(&deal(), 0.0, 100.0), None); + assert_eq!(profit_pct(&deal(), 99.0, f64::NAN), None); + assert_eq!(profit_pct(&deal(), -1.0, 100.0), None); + assert!((profit_pct(&deal(), 100.0, 101.0).unwrap() - 1.0).abs() < 1e-9); +} + +#[test] +fn a_fact_entry_uses_the_report_fill() { + let ticks = tape(&[(9_000, 100.0), (10_000, 99.0), (11_000, 100.5)]); + let out = simulate( + &deal(), + &ticks, + &EntryParams::Fact, + &ExitParams::default(), + None, + ); + assert_eq!( + out.fill, + Some(Fill { + t_ms: 10_000, + price: 99.0 + }) + ); + assert_eq!(out.exit.map(|e| e.kind), Some(ExitKind::Take)); +} + +#[test] +fn an_unfilled_variant_is_not_a_trade() { + let ticks = tape(&[(0, 100.0), (1_000, 99.5)]); + let out = simulate( + &deal(), + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + ); + assert!(!out.is_trade()); + assert_eq!(out.profit_money(&deal()), None); +} + +// ---- verify: reproducing the fact -------------------------------------------------------- + +#[test] +fn verify_marks_an_entry_inside_the_tolerance() { + // Fact buy at 99.0; the model fills at 99.0 exactly → ✓, and the take at 99.99 → ✓ against + // a fact sell of 100.0? No: 0.01 % off is inside 0.05 % → ✓. + let ticks = tape(&[(0, 100.0), (10_000, 99.0), (20_000, 100.0)]); + let v = verify( + &deal(), + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + ); + assert_eq!(v.entry, Some(true)); + assert_eq!(v.exit, Some(true)); + assert!(v.exit_dev_pct.unwrap().abs() < 0.05); +} + +#[test] +fn verify_marks_a_missed_entry_and_leaves_the_fact_exit_unanswered() { + let ticks = tape(&[(0, 100.0), (10_000, 99.5), (20_000, 100.0)]); + let v = verify( + &deal(), + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + ); + assert_eq!(v.entry, Some(false)); + assert_eq!(v.fill, None); + assert_eq!(v.exit, None, "no fill, nothing to exit"); + + // Fact entry, take never reached: the exit is the fact and answers nothing. + let ticks = tape(&[ + (9_000, 100.0), + (10_000, 99.0), + (20_000, 99.5), + (25_000, 99.5), + ]); + let v = verify( + &deal(), + &ticks, + &EntryParams::Fact, + &ExitParams::default(), + None, + ); + assert_eq!(v.entry, None); + assert_eq!(v.exit, None); + assert_eq!(v.exit_kind, Some(ExitKind::Fact)); +} + +#[test] +fn verify_reports_the_deviation_of_an_entry_off_the_fact() { + let mut d = deal(); + d.buy_price = 98.0; + let ticks = tape(&[(0, 100.0), (10_000, 99.0)]); + let v = verify( + &d, + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + ); + assert_eq!(v.entry, Some(false)); + let dev = v.entry_dev_pct.unwrap(); + assert!((dev - 99.0 / 98.0 * 100.0 + 100.0).abs() < 1e-6, "{dev}"); +} + +#[test] +fn verify_leaves_a_take_unanswered_against_a_fact_another_rule_closed() { + // The tape reaches the take, but the core closed by Auto Price Down: not the same rule, + // so the exit neither hits nor misses. + let mut d = deal(); + d.sell_reason = "Auto Price Down".into(); + let ticks = tape(&[(0, 100.0), (10_000, 99.0), (20_000, 100.0)]); + let v = verify( + &d, + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + ); + assert_eq!(v.exit_kind, Some(ExitKind::Take)); + assert_eq!(v.exit, None); + assert_eq!(v.exit_dev_pct, None); +} + +#[test] +fn share_counts_only_answered_verdicts() { + assert_eq!(share([Some(true), None, Some(false), Some(true)]), (2, 3)); + assert_eq!(share([None, None]), (0, 0)); +} + +// ---- parameters out of a strategy ---------------------------------------------------------- + +fn values(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +#[test] +fn mshot_params_read_the_strategy_then_the_schema_then_the_model_default() { + let v = values(&[ + ("MShotPrice", "1.4"), + ("MShotPriceMin", "1,1"), + ("MShotUsePrice", "Trade"), + ("MShotMinusSatoshi", "YES"), + ("MShotAdd3hDelta", "0.0005"), + ("MShotAddDistance", "50"), + ]); + let defaults: HashMap = [("mshotreplacedelay".to_string(), 0.3)].into(); + let p = mshot_params( + &StrategyValues { + values: &v, + defaults: &defaults, + }, + DEFAULT_LATENCY_MS, + ); + assert!((p.price_pct - 1.4).abs() < 1e-9); + assert!( + (p.price_min_pct - 1.1).abs() < 1e-9, + "a comma decimal parses" + ); + assert!(p.minus_satoshi); + assert!( + (p.replace_delay_s - 0.3).abs() < 1e-9, + "the schema default fills a missing key" + ); + assert!( + (p.raise_wait_s - 0.0).abs() < 1e-9, + "the model default fills the rest" + ); + assert!((p.modifiers.add_3h - 0.0005).abs() < 1e-12); + assert!((p.modifiers.distance_pct - 50.0).abs() < 1e-9); +} + +#[test] +fn exit_params_read_the_sell_fields() { + let v = values(&[ + ("SellPrice", "0.8%"), + ("MShotSellAtLastPrice", "NO"), + ("MShotSellPriceAdjust", "1"), + ]); + let defaults = HashMap::new(); + let p = exit_params(&StrategyValues { + values: &v, + defaults: &defaults, + }); + assert!((p.sell_price_pct - 0.8).abs() < 1e-9); + assert!(!p.sell_at_last_price); + assert!((p.sell_price_adjust_pct - 1.0).abs() < 1e-9); +} + +#[test] +fn the_descriptor_keys_every_field_the_builders_read_and_splits_the_groups() { + let keys = param_keys(); + for key in [ + "MShotPrice", + "MShotPriceMin", + "MShotUsePrice", + "MShotRaiseWait", + "MShotReplaceDelay", + "MShotMinusSatoshi", + "MShotAddHourlyDelta", + "MShotAddPriceBug", + "MShotAddDistance", + "SellPrice", + "MShotSellAtLastPrice", + "MShotSellPriceAdjust", + "SellDelay", + ] { + assert!( + keys.iter().any(|k| k == key), + "{key} missing from TICK_PARAMS" + ); + } + assert!(params_for(ParamGroup::Entry, "Spread").next().is_none()); + assert!(params_for(ParamGroup::Entry, "MoonShot").count() > 10); + let exit_any: Vec<_> = params_for(ParamGroup::Exit, "Spread") + .map(|p| p.key) + .collect(); + assert_eq!(exit_any, ["SellPrice", "SellDelay"]); + assert!(entry_model_for("MoonShot") && !entry_model_for("Spread")); +} + +// ---- the price step off the tape ----------------------------------------------------------- + +#[test] +fn infer_tick_reads_the_grid_and_snaps_float_noise() { + let ticks = tape(&[(0, 1.2345), (1, 1.2346), (2, 1.2349), (3, 1.2346)]); + let step = infer_tick(&ticks).expect("a step"); + assert!((step - 0.0001).abs() < 1e-12, "{step}"); + assert_eq!(infer_tick(&tape(&[(0, 1.0), (1, 1.0)])), None); + assert_eq!(infer_tick(&[]), None); +} diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs new file mode 100644 index 00000000..d8dc83cd --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -0,0 +1,216 @@ +//! The model against a real data root — the phase-1 acceptance run, not a unit test. +//! +//! Ignored by default: it needs a data root and prints rather than asserts. Run it as +//! `MOON_TICKS_DATA_DIR= cargo test -p moon-core --target x86_64-pc-windows-msvc --lib +//! db::tuner::ticks::tests::real_data -- --ignored --nocapture`. It reads every MoonShot row +//! with millisecond stamps, takes its prints from `trades.sqlite` and its entry line from +//! `order_traces.sqlite`, runs [`verify`] on the parameters as of the buy, and prints one line +//! per deal plus the ✓ share per group. The environment variable is read HERE only, in a test a +//! developer runs by hand; the application never moves its data root on a variable. + +use std::collections::HashMap; +use std::path::PathBuf; + +use rusqlite::{Connection, OpenFlags}; + +use super::super::mshot::DEFAULT_LATENCY_MS; +use super::super::params::{StrategyValues, exit_params, mshot_params, param_keys}; +use super::super::*; +use crate::config::paths; +use crate::db::order_traces::{TraceEntry, read_many}; +use crate::db::tuner::strategy_values_at; +use crate::feed::report_traces::ArchivedLineKind; +use crate::market::trade_replay::trade_cache::TradeCache; +use crate::symbol::{coin_match_key, coin_of_market}; + +/// The tape must reach this far back before the buy for the corridor to have a run-up. +const RUN_UP_MS: i64 = 30_000; + +fn read_deals(reports: &Connection) -> Vec { + let mut rows = reports + .prepare( + "SELECT reportuid, core_uid, strategyid, coin, buydatems, closedatems, + buyprice, sellprice, spentbtc, isshort, sellreason, + d1m, d5m, d15m, d1h, d3h, d24h, dmark, pricebug, btc1hdelta, btc5mdelta, + exchange1hdelta + FROM orders_rep + WHERE buydatems > 0 AND closedatems > 0 AND deleted = 0 + ORDER BY buydatems", + ) + .expect("query"); + rows.query_map([], |r| { + let num = |i: usize| -> f64 { r.get::<_, Option>(i).ok().flatten().unwrap_or(0.0) }; + Ok(Deal { + report_uid: r.get(0)?, + core_uid: r.get::<_, i64>(1)? as u64, + strategy_id: r.get(2)?, + kind: String::new(), + coin: r.get::<_, Option>(3)?.unwrap_or_default(), + buy_ms: r.get(4)?, + close_ms: r.get(5)?, + buy_price: num(6), + sell_price: num(7), + spent: num(8), + is_short: r.get::<_, Option>(9)?.unwrap_or(0) != 0, + sell_reason: r.get::<_, Option>(10)?.unwrap_or_default(), + deltas: Deltas { + d1m: num(11), + d5m: num(12), + d15m: num(13), + d1h: num(14), + d3h: num(15), + d24h: num(16), + dmark: num(17), + pricebug: num(18), + btc1h: num(19), + btc5m: num(20), + market1h: num(21), + }, + tick: None, + }) + }) + .expect("rows") + .flatten() + .collect() +} + +/// The archived first point of the deal's own entry line, when the archive holds one. +fn archived_entry_start(deal: &Deal) -> Option<(i64, f64)> { + let entries = read_many(deal.core_uid, &[deal.report_uid]).ok()?; + match entries.get(&deal.report_uid)? { + TraceEntry::Lines(lines) => lines + .iter() + .find(|l| l.own && l.kind == ArchivedLineKind::Entry) + .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))), + TraceEntry::Empty { .. } => None, + } +} + +fn round3(v: Option) -> Option { + v.map(|d| (d * 1000.0).round() / 1000.0) +} + +#[test] +#[ignore = "needs a live data root in MOON_TICKS_DATA_DIR"] +fn real_data_reproduction() { + let Some(root) = std::env::var_os("MOON_TICKS_DATA_DIR") else { + eprintln!("MOON_TICKS_DATA_DIR is not set; nothing to do"); + return; + }; + assert!(paths::set_data_dir_override(PathBuf::from(root))); + + let reports = + Connection::open_with_flags(paths::reports_db_path(), OpenFlags::SQLITE_OPEN_READ_ONLY) + .expect("reports.sqlite"); + let deals = read_deals(&reports); + eprintln!("deals with ms stamps: {}", deals.len()); + + // Every (exchange, market) pair the tape holds — the deal's exchange key is not in the + // report, so a coin is tried under each exchange and market spelling that stores it. + let spans_db = + Connection::open_with_flags(paths::trades_db_path(), OpenFlags::SQLITE_OPEN_READ_ONLY) + .expect("trades.sqlite"); + let pairs: Vec<(String, String)> = spans_db + .prepare("SELECT DISTINCT exchange, market FROM spans") + .expect("spans") + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .expect("pairs") + .flatten() + .collect(); + let cache = TradeCache::open(paths::trades_db_path()).expect("cache"); + let margin_ms = crate::market::trade_replay::margin_ms(); + + let mut keys = param_keys(); + keys.push("SignalType".into()); + let defaults = HashMap::new(); + let (mut entry_hits, mut entry_n, mut exit_hits, mut exit_n, mut with_tape) = (0, 0, 0, 0, 0); + let mut kinds_seen: HashMap = HashMap::new(); + for mut deal in deals { + let Some(values) = + strategy_values_at(deal.strategy_id, Some(deal.core_uid), deal.buy_ms, &keys) + else { + continue; + }; + deal.kind = values.get("SignalType").cloned().unwrap_or_default(); + *kinds_seen.entry(deal.kind.clone()).or_default() += 1; + if !entry_model_for(&deal.kind) { + continue; + } + let mut ticks: Vec = Vec::new(); + let coin_key = coin_match_key(&deal.coin); + for (exchange, market) in pairs + .iter() + .filter(|(_, m)| coin_match_key(coin_of_market(m)) == coin_key) + { + let Some(spans) = cache.read( + exchange, + market, + deal.buy_ms - margin_ms, + deal.close_ms + margin_ms, + ) else { + continue; + }; + for span in spans { + ticks.extend(span.ticks); + } + } + ticks.sort_by(|a, b| a.time_ms.total_cmp(&b.time_ms)); + ticks.dedup_by(|a, b| a.time_ms == b.time_ms && a.price == b.price && a.qty == b.qty); + let first = ticks.first().map(|t| t.time_ms as i64); + let last = ticks.last().map(|t| t.time_ms as i64); + if first.is_none_or(|f| f > deal.buy_ms - RUN_UP_MS) + || last.is_none_or(|l| l < deal.close_ms) + { + continue; + } + with_tape += 1; + deal.tick = infer_tick(&ticks); + let entry_start = archived_entry_start(&deal); + let sv = StrategyValues { + values: &values, + defaults: &defaults, + }; + let entry = EntryParams::MoonShot(mshot_params(&sv, DEFAULT_LATENCY_MS)); + let exit = exit_params(&sv); + let plain = verify(&deal, &ticks, &entry, &exit, None); + let archived = verify(&deal, &ticks, &entry, &exit, entry_start); + eprintln!( + "{uid} {coin:<8} buy {buy:.6} | plain fill {fill:?} dev {dev:?} ✓{ok:?} | \ + archived start {start:?} fill {fill2:?} dev {dev2:?} ✓{ok2:?} | \ + exit {exit_kind:?} ✓{exit_ok:?} dev {exit_dev:?} | {reason} | ticks {n} step {tick:?}", + uid = deal.report_uid, + coin = deal.coin, + buy = deal.buy_price, + fill = plain.fill.map(|f| f.price), + dev = round3(plain.entry_dev_pct), + ok = plain.entry, + start = entry_start, + fill2 = archived.fill.map(|f| f.price), + dev2 = round3(archived.entry_dev_pct), + ok2 = archived.entry, + exit_kind = archived.exit_kind, + exit_ok = archived.exit, + exit_dev = round3(archived.exit_dev_pct), + reason = deal.sell_reason, + n = ticks.len(), + tick = deal.tick, + ); + let best = if entry_start.is_some() { + archived + } else { + plain + }; + if let Some(ok) = best.entry { + entry_n += 1; + entry_hits += usize::from(ok); + } + if let Some(ok) = best.exit { + exit_n += 1; + exit_hits += usize::from(ok); + } + } + eprintln!("kinds: {kinds_seen:?}"); + eprintln!( + "with tape: {with_tape} · entry ✓ {entry_hits}/{entry_n} · exit ✓ {exit_hits}/{exit_n}" + ); +} diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs new file mode 100644 index 00000000..01343a30 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -0,0 +1,112 @@ +//! Reproducing the fact — the check every search has to pass before it may run. +//! +//! The model is run with the strategy's parameters AS THEY WERE at the trade and its answer is +//! held against the report row, per group: did the entry fill where the core's did, did the +//! exit land where the core's did. The share of ✓ over the sample is the caption's "model ✓ +//! K %", and a group below the caller's threshold is not searched at all — a search over a model +//! that cannot reproduce what happened optimizes noise. +//! +//! A group the model does not cover answers `None`, not `false`: a Spread's entry is not wrong, +//! it is not modelled, and phase 1's exit is not modelled wherever the take did not close it. + +use super::{Deal, EntryParams, ExitKind, ExitParams, Fill, PRICE_TOLERANCE, simulate}; +use crate::feed::types::Tick; + +/// One trade's reproduction verdict, per group. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Verdict { + /// Entry reproduced: `Some(true)` within tolerance, `Some(false)` off or unfilled, `None` + /// when the kind has no entry model (the entry was taken from the fact). + pub entry: Option, + /// Modelled fill against the fact, per cent of the fact (`None` when unfilled or unmodelled). + pub entry_dev_pct: Option, + /// Exit reproduced; `None` when no exit rule decided (the exit was taken from the fact), + /// when there was no fill to exit from, or when the core closed by a rule the model does + /// not have yet (`sellreason` is not the take's) — the two prices are not comparable then. + pub exit: Option, + /// Modelled exit against the fact, per cent of the fact. + pub exit_dev_pct: Option, + /// The modelled fill, for the tooltip. + pub fill: Option, + /// How the modelled position closed, when it filled. + pub exit_kind: Option, +} + +/// Relative deviation of `modelled` from `fact`, per cent of the fact. +fn deviation_pct(modelled: f64, fact: f64) -> Option { + if !fact.is_finite() || fact <= 0.0 || !modelled.is_finite() { + return None; + } + Some((modelled - fact) / fact * 100.0) +} + +/// Run the model on the trade's own parameters and compare with the report row. +/// +/// Args: +/// deal: The report row. +/// ticks: Its window's prints, ascending. +/// entry: The entry parameters at the trade — [`EntryParams::Fact`] for a kind without a +/// model, which leaves `Verdict::entry` at `None`. +/// exit: The sell-line parameters at the trade. +/// entry_start: The archived first point of the entry line, when known. +pub fn verify( + deal: &Deal, + ticks: &[Tick], + entry: &EntryParams, + exit: &ExitParams, + entry_start: Option<(i64, f64)>, +) -> Verdict { + let outcome = simulate(deal, ticks, entry, exit, entry_start); + let entry_modelled = !matches!(entry, EntryParams::Fact); + let (entry_ok, entry_dev) = match (entry_modelled, outcome.fill) { + (false, _) => (None, None), + (true, None) => (Some(false), None), + (true, Some(fill)) => { + let dev = deviation_pct(fill.price, deal.buy_price); + let ok = dev.is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0); + (Some(ok), dev) + } + }; + // The exit answers only where the model's rule and the core's were the same rule: a + // modelled take against a fact the core closed by its take. A take held against an + // "Auto Price Down" fact is not a miss of the take model, it is a rule the model does not + // have yet (phase 2), and it stays unanswered until it does. + let (exit_ok, exit_dev) = match outcome.exit { + Some(exit) if exit_rule_matches(exit.kind, &deal.sell_reason) => { + let dev = deviation_pct(exit.price, deal.sell_price); + let ok = dev.is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0); + (Some(ok), dev) + } + _ => (None, None), + }; + Verdict { + entry: entry_ok, + entry_dev_pct: entry_dev, + exit: exit_ok, + exit_dev_pct: exit_dev, + fill: outcome.fill, + exit_kind: outcome.exit.map(|e| e.kind), + } +} + +/// The core's `sellreason` for a position its take closed. +pub const REASON_TAKE: &str = "Sell Price"; + +/// Whether the model's exit rule is the one the core's `sellreason` names, so the two prices +/// are comparable. Only the take is modelled today; the moving line and the stop join in +/// phase 2 with their own reasons (`Auto Price Down`, `StopLoss …`). +fn exit_rule_matches(kind: ExitKind, sell_reason: &str) -> bool { + match kind { + ExitKind::Take => sell_reason.trim().eq_ignore_ascii_case(REASON_TAKE), + ExitKind::Line | ExitKind::Stop | ExitKind::Fact | ExitKind::OpenAtWindowEnd => false, + } +} + +/// Share of ✓ over verdicts that answered, as `(hits, answered)`; the caption prints it and +/// the gate compares it with the threshold. Unanswered verdicts are out of both counts. +pub fn share(verdicts: impl IntoIterator>) -> (usize, usize) { + verdicts + .into_iter() + .flatten() + .fold((0, 0), |(hits, n), ok| (hits + usize::from(ok), n + 1)) +} From b67c7d907f418d922352aa00e44340b7a96f42a4 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sun, 20 Sep 2026 12:50:13 +0200 Subject: [PATCH 02/51] feat(tuner): deals and held-tape reads for the Entry/Exit axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../moon-core/src/db/analytics/query/mod.rs | 6 + crates/moon-core/src/db/tuner/mod.rs | 2 +- .../moon-core/src/db/tuner/strategy_read.rs | 52 ++++++ crates/moon-core/src/db/tuner/ticks/deals.rs | 161 +++++++++++++++++ .../src/db/tuner/ticks/deals/tests.rs | 81 +++++++++ crates/moon-core/src/db/tuner/ticks/mod.rs | 5 + crates/moon-core/src/db/tuner/ticks/tests.rs | 1 + .../src/db/tuner/ticks/tests/real_data.rs | 131 ++++++-------- .../moon-core/src/market/trade_replay/mod.rs | 6 +- .../src/market/trade_replay/worker.rs | 165 +++--------------- 10 files changed, 389 insertions(+), 221 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/deals.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/deals/tests.rs diff --git a/crates/moon-core/src/db/analytics/query/mod.rs b/crates/moon-core/src/db/analytics/query/mod.rs index 124d9f9b..aa21c5a2 100644 --- a/crates/moon-core/src/db/analytics/query/mod.rs +++ b/crates/moon-core/src/db/analytics/query/mod.rs @@ -508,6 +508,12 @@ const UNIFIED_COLS: &[&str] = &[ // `spentbtc == boughtq`). It carries real money, so it belongs in profit — but it is not a // trade, and counting it would inflate trade counts, turnover and win rate alike. "sellreason", + // The trade's identity in the order-trace archive and its millisecond stamps: the + // Entry/Exit tuner replays the tape around each row and keys the archive by `reportuid`. + // NULL on a replica that predates the columns, which that axis then counts as "no stamp". + "reportuid", + "buydatems", + "closedatems", ]; /// Money projection resolved by quote coverage before one analytical scan. diff --git a/crates/moon-core/src/db/tuner/mod.rs b/crates/moon-core/src/db/tuner/mod.rs index c25b65b4..7f9a2ad3 100644 --- a/crates/moon-core/src/db/tuner/mod.rs +++ b/crates/moon-core/src/db/tuner/mod.rs @@ -30,7 +30,7 @@ mod time; pub use fields::{FIELDS, FieldClass, FieldSpec, slot_type_for}; pub use strategy_read::{ StratFilters, strategy_cores, strategy_current_values, strategy_current_values_opt, - strategy_filters, strategy_values_at, + strategy_filters, strategy_kinds, strategy_values_at, }; pub use time::{ SliderProfiles, TimeAxes, TimeSuggest, TimeWindow, format_week_span, format_working_time, diff --git a/crates/moon-core/src/db/tuner/strategy_read.rs b/crates/moon-core/src/db/tuner/strategy_read.rs index 9efb8113..d372e35f 100644 --- a/crates/moon-core/src/db/tuner/strategy_read.rs +++ b/crates/moon-core/src/db/tuner/strategy_read.rs @@ -229,6 +229,58 @@ fn load_raw_json_at( } } +/// The strategy KIND (`SignalType`: `MoonShot`, `Spread`, …) of each `(strategy_id, core_uid)` +/// pair, from its newest version — a strategy never changes kind, and a deleted one still has +/// versions to read it from. Pairs with no version at all are absent from the map. +/// +/// Args: +/// pairs: Distinct `(strategy_id, core_uid)` pairs. +/// +/// A database that cannot be opened or queried yields an EMPTY map and one warning: the caller +/// then shows every deal as "kind unknown" (no entry model), which is visible, rather than +/// failing the whole read for a file the axis only annotates from. +pub fn strategy_kinds(pairs: &[(i64, u64)]) -> std::collections::HashMap<(i64, u64), String> { + let mut out = std::collections::HashMap::new(); + if pairs.is_empty() { + return out; + } + let Some(conn) = open_strategies_ro() else { + log::warn!("[x] tuner: strategies.sqlite unavailable, strategy kinds unresolved"); + return out; + }; + let mut stmt = match conn.prepare( + "SELECT json_extract(v.raw_json, '$.SignalType') FROM strategy_versions v + WHERE v.strategy_id = ?1 AND v.core_uid = ?2 + ORDER BY v.valid_to IS NULL DESC, v.valid_from DESC LIMIT 1", + ) { + Ok(stmt) => stmt, + Err(error) => { + log::warn!("[x] tuner: strategy kinds query failed to prepare: {error}"); + return out; + } + }; + let mut failed = 0usize; + for &(strategy_id, core_uid) in pairs { + match stmt.query_row(rusqlite::params![strategy_id, core_uid as i64], |r| { + r.get::<_, Option>(0) + }) { + Ok(Some(kind)) => { + out.insert((strategy_id, core_uid), kind); + } + // No version at all, or a version without the field: genuinely unknown. + Ok(None) | Err(rusqlite::Error::QueryReturnedNoRows) => {} + Err(_) => failed += 1, + } + } + if failed > 0 { + log::warn!( + "[x] tuner: strategy kinds unresolved for {failed} of {} strategies (query errors)", + pairs.len() + ); + } + out +} + /// `keys` out of one version's `raw_json`, in strategy format — see /// [`strategy_current_values_opt`] for the rules. fn flatten_values(raw: &str, keys: &[String]) -> Option> { diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs new file mode 100644 index 00000000..e7688ae8 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -0,0 +1,161 @@ +//! The axis' report rows: every closed trade of the tuner scope, as [`Deal`]s. +//! +//! Read through the same unified source the other axes scan (`read_tuner_rows`), so the +//! "Fact" column and the tape replay describe the SAME trades — period, cores, strategies, +//! emulator and side filters included. A row without a millisecond stamp cannot be replayed +//! (the tape is sub-second) and is counted rather than dropped silently; the caption prints +//! the count. + +use rusqlite::Connection; + +use super::{Deal, Deltas}; +use crate::db::analytics::Query; +use crate::db::read_fail::read_fail_on; +use crate::db::tuner::strategy_kinds; +use crate::db::{ReadFail, ReadResult}; + +/// The scope's rows split into what the axis can replay and what it cannot. +#[derive(Clone, Debug, Default)] +pub struct DealsRead { + /// Rows with millisecond stamps, chronological by close. + pub deals: Vec, + /// Rows the scope holds that carry no millisecond stamp — older replicas, or a core that + /// predates the stamps. In the "Fact" column, not in the replay. + pub without_ms: usize, +} + +/// The delta columns in the order [`Deltas`] is filled below; every one is a `FIELDS` column, +/// so the unified source projects it (NULL when the replica lacks it). +const DELTA_COLS: [&str; 11] = [ + "d1m", + "d5m", + "d15m", + "d1h", + "d3h", + "d24h", + "dmark", + "pricebug", + "btc1hdelta", + "btc5mdelta", + "exchange1hdelta", +]; + +/// Read the scope's closed trades as deals. +/// +/// Args: +/// q: The tuner scope — period, cores, strategies, filters. +/// +/// Returns: +/// The replayable deals with their kinds resolved, and the count left out; `NotReady` when +/// no report source has the schema yet. +pub fn read_deals(q: &Query) -> ReadResult { + let mut read = crate::db::tuner::read_tuner_rows(q, read_on)?; + // The kind selects the entry model; resolved once per distinct strategy, off the replica's + // snapshot, because it lives in strategies.sqlite. + let mut pairs: Vec<(i64, u64)> = read + .deals + .iter() + .map(|d| (d.strategy_id, d.core_uid)) + .collect(); + pairs.sort_unstable(); + pairs.dedup(); + let kinds = strategy_kinds(&pairs); + for deal in &mut read.deals { + if let Some(kind) = kinds.get(&(deal.strategy_id, deal.core_uid)) { + deal.kind = kind.clone(); + } + } + Ok(read) +} + +/// The scan itself, inside the pinned snapshot. Ordered in memory by the millisecond close — +/// the unified source has no total order to ask SQL for. +fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { + const CTX: &str = "tuner: ticks deals"; + let deltas = DELTA_COLS + .iter() + .map(|c| format!("o.\"{c}\"")) + .collect::>() + .join(", "); + let sql = format!( + "SELECT o.\"reportuid\", o.\"core_uid\", o.\"strategyid\", o.\"coin\", + o.\"buydatems\", o.\"closedatems\", o.\"buyprice\", o.\"sellprice\", + o.\"spentbtc\", o.\"isshort\", o.\"sellreason\", COALESCE(o.pnl, 0), {deltas} + FROM {src}" + ); + let mut stmt = conn.prepare(&sql).map_err(|e| read_fail_on(conn, CTX, e))?; + let mut rows = stmt + .query(rusqlite::params![q.from, q.to]) + .map_err(|e| read_fail_on(conn, CTX, e))?; + let mut out = DealsRead::default(); + let mut order: Vec<(i64, i64)> = Vec::new(); + while let Some(r) = rows.next().map_err(|e| read_fail_on(conn, CTX, e))? { + let fail = |e: rusqlite::Error| -> ReadFail { read_fail_on(conn, CTX, e) }; + let num = |i: usize| -> Result { + Ok(r.get::<_, Option>(i) + .map_err(fail)? + .filter(|v| v.is_finite()) + .unwrap_or(0.0)) + }; + let int = |i: usize| -> Result { + Ok(r.get::<_, Option>(i).map_err(fail)?.unwrap_or(0)) + }; + let buy_ms = int(4)?; + let close_ms = int(5)?; + if buy_ms <= 0 || close_ms <= 0 { + out.without_ms += 1; + continue; + } + let mut deltas = Deltas::default(); + let slots: [&mut f64; 11] = [ + &mut deltas.d1m, + &mut deltas.d5m, + &mut deltas.d15m, + &mut deltas.d1h, + &mut deltas.d3h, + &mut deltas.d24h, + &mut deltas.dmark, + &mut deltas.pricebug, + &mut deltas.btc1h, + &mut deltas.btc5m, + &mut deltas.market1h, + ]; + for (offset, slot) in slots.into_iter().enumerate() { + *slot = num(12 + offset)?; + } + let report_uid = int(0)?; + out.deals.push(Deal { + report_uid, + core_uid: int(1)? as u64, + strategy_id: int(2)?, + kind: String::new(), + coin: r + .get::<_, Option>(3) + .map_err(fail)? + .unwrap_or_default(), + buy_ms, + close_ms, + buy_price: num(6)?, + sell_price: num(7)?, + spent: num(8)?, + is_short: int(9)? != 0, + sell_reason: r + .get::<_, Option>(10) + .map_err(fail)? + .unwrap_or_default(), + fact_pnl: num(11)?, + deltas, + tick: None, + }); + order.push((close_ms, report_uid)); + } + // Chronological by the millisecond close, ties broken by the row's identity so the order is + // total. + let mut index: Vec = (0..out.deals.len()).collect(); + index.sort_by_key(|&i| order[i]); + out.deals = index.into_iter().map(|i| out.deals[i].clone()).collect(); + Ok(out) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/deals/tests.rs b/crates/moon-core/src/db/tuner/ticks/deals/tests.rs new file mode 100644 index 00000000..f519e2d3 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deals/tests.rs @@ -0,0 +1,81 @@ +//! The deal read on an in-memory replica: the millisecond gate, the deltas, the order. + +use rusqlite::Connection; + +use super::*; +use crate::db::tuner::tuner_source_on; + +fn replica() -> Connection { + let conn = Connection::open_in_memory().expect("in-memory database"); + conn.execute_batch( + "CREATE TABLE orders_rep( + reportuid INTEGER, core_uid INTEGER, strategyid INTEGER, coin TEXT, + buydate INTEGER, closedate INTEGER, buydatems INTEGER, closedatems INTEGER, + buyprice REAL, sellprice REAL, spentbtc REAL, profitbtc REAL, isshort INTEGER, + sellreason TEXT, basecurrency INTEGER, d1h REAL, d3h REAL, pricebug REAL + ); + INSERT INTO orders_rep VALUES + (11, 7, 42, 'ACE', 100, 200, 100000, 200000, 99.0, 100.0, 1000.0, 10.0, 0, + 'Sell Price', 1, 4.2, 6.8, 0.5), + (12, 7, 42, 'BEN', 150, 180, 150000, 180000, 50.0, 49.0, 500.0, -10.0, 1, + 'Auto Price Down', 1, NULL, -1.0, 0.0), + (13, 7, 42, 'OLD', 110, 190, 0, 0, 1.0, 1.1, 100.0, 10.0, 0, + 'Sell Price', 1, 0.0, 0.0, 0.0);", + ) + .expect("fixture"); + conn +} + +fn scope() -> Query { + Query { + from: 1, + to: 1_000, + metric: crate::db::ProfitMetric::Percent, + ..Default::default() + } +} + +#[test] +fn rows_with_stamps_become_deals_and_the_rest_are_counted() { + let conn = replica(); + let (q, src) = tuner_source_on(&conn, &scope()).expect("source"); + let read = read_on(&conn, &q, &src).expect("read"); + assert_eq!(read.without_ms, 1, "the row without millisecond stamps"); + assert_eq!(read.deals.len(), 2); + // Chronological by the millisecond close: BEN (180 000) before ACE (200 000). + assert_eq!(read.deals[0].report_uid, 12); + assert_eq!(read.deals[1].report_uid, 11); + let ace = &read.deals[1]; + assert_eq!(ace.coin, "ACE"); + assert_eq!((ace.buy_ms, ace.close_ms), (100_000, 200_000)); + assert!(!ace.is_short && read.deals[0].is_short); + assert_eq!(ace.sell_reason, "Sell Price"); + assert!((ace.deltas.d1h - 4.2).abs() < 1e-9); + assert!((ace.deltas.d3h - 6.8).abs() < 1e-9); + assert!((ace.deltas.pricebug - 0.5).abs() < 1e-9); + // Percent metric: 10 / 1000 · 100. + assert!((ace.fact_pnl - 1.0).abs() < 1e-9, "{}", ace.fact_pnl); + // A NULL delta reads as zero, never as a missing row. + assert_eq!(read.deals[0].deltas.d1h, 0.0); + assert!( + read.deals.iter().all(|d| d.kind.is_empty()), + "kinds are resolved by the caller" + ); +} + +#[test] +fn a_replica_without_the_stamp_columns_yields_no_deals() { + let conn = Connection::open_in_memory().expect("in-memory database"); + conn.execute_batch( + "CREATE TABLE orders_rep( + closedate INTEGER, core_uid INTEGER, profitbtc REAL, spentbtc REAL, + basecurrency INTEGER + ); + INSERT INTO orders_rep VALUES (100, 1, 10.0, 100.0, 1);", + ) + .expect("fixture"); + let (q, src) = tuner_source_on(&conn, &scope()).expect("source"); + let read = read_on(&conn, &q, &src).expect("read"); + assert!(read.deals.is_empty()); + assert_eq!(read.without_ms, 1); +} diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 53c5e6f9..ae553d41 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -24,12 +24,14 @@ use crate::feed::types::Tick; +pub mod deals; pub mod entry; pub mod exit; pub mod mshot; pub mod params; pub mod verify; +pub use deals::{DealsRead, read_deals}; pub use entry::{EntryModel, entry_model_for}; pub use exit::{ExitModel, ExitParams}; pub use mshot::{MshotEntry, MshotParams, UsePrice}; @@ -115,6 +117,9 @@ pub struct Deal { pub is_short: bool, /// `sellreason` as the core wrote it (`"Auto Price Down"`, `"Sell Price"`, …). pub sell_reason: String, + /// The row's result in the scope's active metric (`pnl` of the unified source) — what the + /// "Fact" column over the same subset sums. + pub fact_pnl: f64, pub deltas: Deltas, /// Price step of the market, when the caller could resolve it (the live catalog, or /// [`infer_tick`] over the window). `None` disables the step-bound rules and rounds nothing. diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index c88cb446..f5d73081 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -40,6 +40,7 @@ fn deal() -> Deal { spent: 1_000.0, is_short: false, sell_reason: "Sell Price".into(), + fact_pnl: 10.0, deltas: Deltas::default(), tick: None, } diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index d8dc83cd..52c08216 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -2,14 +2,18 @@ //! //! Ignored by default: it needs a data root and prints rather than asserts. Run it as //! `MOON_TICKS_DATA_DIR= cargo test -p moon-core --target x86_64-pc-windows-msvc --lib -//! db::tuner::ticks::tests::real_data -- --ignored --nocapture`. It reads every MoonShot row -//! with millisecond stamps, takes its prints from `trades.sqlite` and its entry line from -//! `order_traces.sqlite`, runs [`verify`] on the parameters as of the buy, and prints one line -//! per deal plus the ✓ share per group. The environment variable is read HERE only, in a test a -//! developer runs by hand; the application never moves its data root on a variable. +//! db::tuner::ticks::tests::real_data -- --ignored --nocapture`. It reads every deal of the +//! whole history through [`read_deals`] — the path the axis itself uses — takes each MoonShot +//! deal's prints through the worker's held-data query (`query_held`, the path the table's +//! coverage column uses) and its entry line from `order_traces.sqlite`, runs [`verify`] on the +//! parameters as of the buy, and prints one line per deal plus the ✓ share per group. The +//! environment variable is read HERE only, in a test a developer runs by hand; the application +//! never moves its data root on a variable. use std::collections::HashMap; use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Duration; use rusqlite::{Connection, OpenFlags}; @@ -17,63 +21,16 @@ use super::super::mshot::DEFAULT_LATENCY_MS; use super::super::params::{StrategyValues, exit_params, mshot_params, param_keys}; use super::super::*; use crate::config::paths; +use crate::db::analytics::Query; use crate::db::order_traces::{TraceEntry, read_many}; use crate::db::tuner::strategy_values_at; use crate::feed::report_traces::ArchivedLineKind; -use crate::market::trade_replay::trade_cache::TradeCache; +use crate::market::trade_replay::{Coverage, TickQuery, query_held}; use crate::symbol::{coin_match_key, coin_of_market}; /// The tape must reach this far back before the buy for the corridor to have a run-up. const RUN_UP_MS: i64 = 30_000; -fn read_deals(reports: &Connection) -> Vec { - let mut rows = reports - .prepare( - "SELECT reportuid, core_uid, strategyid, coin, buydatems, closedatems, - buyprice, sellprice, spentbtc, isshort, sellreason, - d1m, d5m, d15m, d1h, d3h, d24h, dmark, pricebug, btc1hdelta, btc5mdelta, - exchange1hdelta - FROM orders_rep - WHERE buydatems > 0 AND closedatems > 0 AND deleted = 0 - ORDER BY buydatems", - ) - .expect("query"); - rows.query_map([], |r| { - let num = |i: usize| -> f64 { r.get::<_, Option>(i).ok().flatten().unwrap_or(0.0) }; - Ok(Deal { - report_uid: r.get(0)?, - core_uid: r.get::<_, i64>(1)? as u64, - strategy_id: r.get(2)?, - kind: String::new(), - coin: r.get::<_, Option>(3)?.unwrap_or_default(), - buy_ms: r.get(4)?, - close_ms: r.get(5)?, - buy_price: num(6), - sell_price: num(7), - spent: num(8), - is_short: r.get::<_, Option>(9)?.unwrap_or(0) != 0, - sell_reason: r.get::<_, Option>(10)?.unwrap_or_default(), - deltas: Deltas { - d1m: num(11), - d5m: num(12), - d15m: num(13), - d1h: num(14), - d3h: num(15), - d24h: num(16), - dmark: num(17), - pricebug: num(18), - btc1h: num(19), - btc5m: num(20), - market1h: num(21), - }, - tick: None, - }) - }) - .expect("rows") - .flatten() - .collect() -} - /// The archived first point of the deal's own entry line, when the archive holds one. fn archived_entry_start(deal: &Deal) -> Option<(i64, f64)> { let entries = read_many(deal.core_uid, &[deal.report_uid]).ok()?; @@ -86,6 +43,20 @@ fn archived_entry_start(deal: &Deal) -> Option<(i64, f64)> { } } +/// The held prints for a deal under one `(exchange, market)` spelling, through the worker. +fn held_ticks(exchange_key: &str, market: &str, from_ms: i64, to_ms: i64) -> Vec { + let (reply, rx) = mpsc::channel(); + query_held(TickQuery { + exchange_key: exchange_key.to_string(), + market: market.to_string(), + spans: Coverage::one((from_ms, to_ms)), + reply, + }); + rx.recv_timeout(Duration::from_secs(10)) + .map(|answer| answer.ticks) + .unwrap_or_default() +} + fn round3(v: Option) -> Option { v.map(|d| (d * 1000.0).round() / 1000.0) } @@ -98,12 +69,28 @@ fn real_data_reproduction() { return; }; assert!(paths::set_data_dir_override(PathBuf::from(root))); + // The replica reader needs the process lease the application takes at start; a running + // terminal holds it, and the probe then has no honest way in. + let Some(_permit) = crate::db::report_recovery::prepare() else { + eprintln!( + "reports replica lease unavailable ({:?}): close the terminal on this data root first", + crate::db::report_recovery::status() + ); + return; + }; - let reports = - Connection::open_with_flags(paths::reports_db_path(), OpenFlags::SQLITE_OPEN_READ_ONLY) - .expect("reports.sqlite"); - let deals = read_deals(&reports); - eprintln!("deals with ms stamps: {}", deals.len()); + let scope = Query { + from: -1, + to: crate::db::analytics::ANALYTICS_HORIZON_SECS, + metric: crate::db::ProfitMetric::Percent, + ..Default::default() + }; + let read = read_deals(&scope).expect("deals"); + eprintln!( + "deals with ms stamps: {} · without: {}", + read.deals.len(), + read.without_ms + ); // Every (exchange, market) pair the tape holds — the deal's exchange key is not in the // report, so a coin is tried under each exchange and market spelling that stores it. @@ -117,42 +104,34 @@ fn real_data_reproduction() { .expect("pairs") .flatten() .collect(); - let cache = TradeCache::open(paths::trades_db_path()).expect("cache"); let margin_ms = crate::market::trade_replay::margin_ms(); - let mut keys = param_keys(); - keys.push("SignalType".into()); + let keys = param_keys(); let defaults = HashMap::new(); let (mut entry_hits, mut entry_n, mut exit_hits, mut exit_n, mut with_tape) = (0, 0, 0, 0, 0); let mut kinds_seen: HashMap = HashMap::new(); - for mut deal in deals { + for mut deal in read.deals { + *kinds_seen.entry(deal.kind.clone()).or_default() += 1; + if !entry_model_for(&deal.kind) { + continue; + } let Some(values) = strategy_values_at(deal.strategy_id, Some(deal.core_uid), deal.buy_ms, &keys) else { continue; }; - deal.kind = values.get("SignalType").cloned().unwrap_or_default(); - *kinds_seen.entry(deal.kind.clone()).or_default() += 1; - if !entry_model_for(&deal.kind) { - continue; - } - let mut ticks: Vec = Vec::new(); let coin_key = coin_match_key(&deal.coin); + let mut ticks: Vec = Vec::new(); for (exchange, market) in pairs .iter() .filter(|(_, m)| coin_match_key(coin_of_market(m)) == coin_key) { - let Some(spans) = cache.read( + ticks.extend(held_ticks( exchange, market, deal.buy_ms - margin_ms, deal.close_ms + margin_ms, - ) else { - continue; - }; - for span in spans { - ticks.extend(span.ticks); - } + )); } ticks.sort_by(|a, b| a.time_ms.total_cmp(&b.time_ms)); ticks.dedup_by(|a, b| a.time_ms == b.time_ms && a.price == b.price && a.qty == b.qty); diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index 2b2ce7f6..f07ec46a 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -42,10 +42,8 @@ use crate::market::candles::ChartCandle; use crate::market::{CandleReadParams, ChartHistoryBuffers, ChartHistoryRead}; use crate::venue::{Brand, Venue}; pub use coverage::Coverage; -pub use settings::{ - cleanup_at_startup, long_position_ms, margin_ms, set_cleanup_at_startup, set_long_position_min, - set_margin_s, -}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU32, Ordering}; pub use worker::{TickAnswer, TickQuery, query_held}; /// Milliseconds in one minute, the only timeframe a replay is fetched at. diff --git a/crates/moon-core/src/market/trade_replay/worker.rs b/crates/moon-core/src/market/trade_replay/worker.rs index 440a9125..c61d1aa7 100644 --- a/crates/moon-core/src/market/trade_replay/worker.rs +++ b/crates/moon-core/src/market/trade_replay/worker.rs @@ -257,8 +257,9 @@ const CAPTURE_SETTLE_SLACK: Duration = Duration::from_secs(5); /// /// The Entry/Exit tuner's question: is this trade's window covered, and if so, hand me the /// prints. Asked once per report row of a table, so it must cost a lock and a disk read, never -/// a page. It goes to the coordinator's queue, which no venue call ever holds: the walks run -/// on the lanes, so a table of rows is answered while every venue is being paged. +/// a page. The answer goes to the worker's queue like every other job because the tile store +/// lives on the worker's thread; it is served right after the candle jobs, ahead of any tick +/// walk, so a table of rows is not held behind one window's venue paging. pub struct TickQuery { /// The exchange half of the tile key — [`ReplayAddress::exchange_key`]. pub exchange_key: String, @@ -280,60 +281,11 @@ pub struct TickAnswer { pub covered: Coverage, } -/// What reaches the coordinator's one inbound channel. +/// What reaches the worker's one inbound channel. enum Inbound { Replay(TradeReplayRequest), Capture(CaptureRequest), Held(TickQuery), - /// A lane armed a native follow-up for a request it answered; the coordinator polls it. - /// Boxed for the same reason as [`Job::Native`]. - NativeWait(Box<(TradeReplayRequest, NativeWait)>), - /// Drop every held tile and remembered answer — see [`forget_tiles`]. - ForgetTiles, -} - -/// One unit of a lane's own queue: the venue calls of one request. -/// -/// A candle job and its own tick upgrade are two separate units on purpose: queuing the tick -/// stage inline would make a second report-row double-click on the same host wait behind it for -/// its OWN candles — see [`next_lane_job`], which is what keeps candle jobs strictly ahead. -enum LaneJob { - Candles(TradeReplayRequest), - /// The stage is boxed: it carries the window's bars, several times the request's size. - Ticks(TradeReplayRequest, Box), -} - -/// What every lane shares with the coordinator and with each other. The gate and the two -/// stores were built for one thread and are already behind their own locks; nothing here is -/// thread-affine. -struct Shared { - agent: ureq::Agent, - gate: ReplayGate, - cache: Mutex>, - tiles: Mutex, - /// Back to the coordinator, for the native follow-ups a lane arms. - back: Sender, -} - -/// The handle to one lane thread. -struct Lane { - tx: Sender, -} - -/// What a lane serves: one host's calls of one intent. -type LaneKey = (&'static str, ReplayIntent); - -/// The lane key of a request: the kline route's host — the budget every call of the request -/// is metered under (the trade route derives its host from the same table) — and the intent, -/// so a chart window and the tuner's batch on the same host walk side by side. A venue with no -/// route answers `NoEndpoint` without a call and shares one idle lane per intent. -fn lane_key(request: &TradeReplayRequest) -> LaneKey { - ( - kline_route(request.address.venue) - .map(|route| route.host()) - .unwrap_or(""), - request.intent, - ) } /// One bounded native follow-up independent of public tick-route eligibility. @@ -378,8 +330,9 @@ impl NativeWait { } } -/// Pop the coordinator's next unit of work, by kind: every pending [`Job::Held`], then every -/// [`Job::Native`], then the captures in arrival order; oldest first within each kind. +/// Pop the next unit of work, by kind: every pending [`Job::Candles`], then every +/// [`Job::Held`], then every [`Job::Native`], then the rest ([`Job::Ticks`], [`Job::Capture`]) +/// in arrival order; oldest first within each kind. /// /// Args: /// queue: The coordinator's own pending-work deque. @@ -387,9 +340,10 @@ impl NativeWait { /// Returns: /// The next job to run, or `None` when the queue is empty. fn next_job(queue: &mut VecDeque) -> Option { - // A held-data query costs a lock and a disk read; it goes ahead of the native probes so a - // table asking once per row is answered at once. + // A held-data query costs a lock and a disk read; it goes ahead of the native probes and the + // tick walks so a table asking once per row is not paced by one window's venue paging. for pick in [ + |job: &Job| matches!(job, Job::Candles(_)), |job: &Job| matches!(job, Job::Held(_)), |job: &Job| matches!(job, Job::Native(..)), ] { @@ -400,25 +354,6 @@ fn next_job(queue: &mut VecDeque) -> Option { queue.pop_front() } -/// Pop a lane's next unit of work: every pending candle job first, then the tick stages in -/// arrival order — a second window on the same host gets its bars before the first window's -/// paging starts. -/// -/// Args: -/// queue: The lane's own pending-work deque. -/// -/// Returns: -/// The next job to run, or `None` when the queue is empty. -fn next_lane_job(queue: &mut VecDeque) -> Option { - if let Some(index) = queue - .iter() - .position(|job| matches!(job, LaneJob::Candles(_))) - { - return queue.remove(index); - } - queue.pop_front() -} - /// Why a tick stage stopped, logged for partial harvests as well as empty abandonments. /// /// `Cancelled` throws away whatever was collected because the window itself closed. Every other @@ -593,17 +528,6 @@ pub fn query_held(query: TickQuery) { send(Inbound::Held(query)); } -/// Drop every tile the worker holds in memory, and every remembered answer with them. -/// -/// For the Storage tab, after it cut `trades.sqlite` down: the disk is the tile store's memory -/// and the two must not disagree about what is held — a held-data query reads the tiles first, -/// and would go on answering "held" for prints the file no longer has until the process -/// restarted. Emptied, the tiles fill again from the trimmed disk on the next ask. Returns at -/// once; a lane mid-walk files what it fetched into the emptied store as it always did. -pub fn forget_tiles() { - send(Inbound::ForgetTiles); -} - fn send(inbound: Inbound) { let worker = WORKER.get_or_init(|| { let (tx, rx) = mpsc::channel::(); @@ -666,29 +590,20 @@ fn enqueue( queue.push_back(Job::Capture(request, spans, false)); } Inbound::Held(query) => queue.push_back(Job::Held(query)), - Inbound::ForgetTiles => { - // Straight here, not through the queue: nothing queued behind it may keep - // answering from tiles the disk has already lost. - *lock_tiles(&shared.tiles) = TickTileStore::default(); - shared - .cache - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clear(); - log::info!("[x] trade-replay tiles and remembered answers dropped after a trim"); - } } } /// Coordinator loop: an internal priority queue, forever. /// -/// Held-data queries, bounded native follow-ups and captures share this queue: held-data first, -/// then native probes, then captures in arrival order ([`next_job`]); none of them calls a -/// venue, so none waits for a lane. A replay request is handed to its host's lane on arrival -/// ([`enqueue`]), and a lane hands back the native follow-up it arms. A capture is an in-process -/// copy out of a core's ring, milliseconds; the settle pass of each capture is timed -/// (`settle_waits`) and enters the queue when due. Idle waits end at the next native probe or -/// settle deadline; otherwise every already-queued message is drained non-blockingly first. +/// Candle, held-data, tick, bounded native follow-up and capture jobs share one queue: candles +/// first, then held-data queries, then native probes, then ticks and captures in arrival order +/// ([`next_job`]). A tick stage is a separate job rather than an inline continuation of its +/// candle job so the first outcome reaches its window before any venue paging starts. A capture is an in-process +/// copy out of a core's ring, milliseconds, so it never holds a tick stage up for long; the +/// settle pass of each capture is timed (`settle_waits`) and enters the queue when due. Idle +/// waits end at the next native probe or settle deadline; otherwise every already-queued +/// request is drained non-blockingly first, so a burst of report-row clicks is batched into +/// the queue before priority is applied rather than served one at a time. /// /// Args: /// rx: Queue of pending requests. @@ -777,12 +692,11 @@ fn run(rx: &Receiver, back: Sender) { } } Job::Held(query) => { - let answer = held_answer(tiles, &query); + let answer = held_answer(&tiles, &query); // A dead receiver is the asker gone — a closed table — and costs nothing more. let _ = query.reply.send(answer); } - Job::Native(native) => { - let (request, mut wait) = *native; + Job::Native(request, mut wait) => { if request.cancel.load(Ordering::Relaxed) { continue; } @@ -1604,39 +1518,10 @@ fn serve_ticks( let key: TileKey = (request.address.exchange_key.clone(), request.market.clone()); let focus = request.window.focus_spans(); let persisted = super::trade_cache::handle(); - // A requester that reads the tiles gets the ring THROUGH them: what the ring holds inside - // the focus is filed as `Core` tiles before the stage decides what is left to fetch, so a - // trade the close-time capture missed costs the venue only what the ring does not hold. - // Run after the disk hydrate on either branch below, and only for a focus the store does - // not already hold whole: the ring copy scans the donor's whole retained ring, and a - // retry of a row the disk answered would pay it for nothing. - let file_ring = || { - if !request.intent.files_core() { - return; - } - let held_whole = { - let store = lock_tiles(tiles); - held_coverage(&store, &key, &focus, Coverage::none()).covers(&focus) - }; - if held_whole { - return; - } - file_core_into_tiles(&request.address, &request.market, &focus, tiles, |span| { - request.address.history.capture_core_span( - &request.address, - &request.market, - span.0, - span.1, - ) - }); - }; - // No venue to ask — none has a route, or the focus is past the route's retention: the focus - // is served from what the tiles hold inside it — a capture from the core's archive, the - // tuner's fetch, an earlier window — or the window prints `none`, the reason there is no - // venue to ask. - let serve_held = |none: TickStatus| { + let Some(route) = stage.route else { hydrate(tiles, persisted.as_ref(), &key, &focus); - file_ring(); + // No venue to ask: the focus is served from what the tiles hold inside it — a capture + // from the core's archive — or the window prints that there is no route, as before. let (covered, runs) = { let store = lock_tiles(tiles); let covered = held_coverage(&store, &key, &focus, Coverage::none()); @@ -1690,8 +1575,8 @@ fn serve_ticks( retention_ms: route.retention_ms().unwrap_or(0), }); } + // After the retention refusal, which is free: a window too old for the route pays no read. hydrate(tiles, persisted.as_ref(), &key, &focus); - file_ring(); let residual = residual_plan(&plan, &lock_tiles(tiles), &key); // The one line that tells a neighbouring window apart from a reopen: the focus is the // window's own, the spans are what the store made of it. In milliseconds, not slices — a From e64b1439b51ac13aa418dc4eaa1652847cbb635d Mon Sep 17 00:00:00 2001 From: guyverino Date: Sun, 20 Sep 2026 13:53:38 +0200 Subject: [PATCH 03/51] feat(tuner): Entry/Exit axis UI (phase 1), the sell-line rules and the tape search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/config/layout.rs | 5 + crates/moon-core/src/config/layout/tests.rs | 16 +- crates/moon-core/src/db/mod.rs | 2 +- crates/moon-core/src/db/read_cancel.rs | 9 +- crates/moon-core/src/db/tuner/mod.rs | 2 +- .../src/db/tuner/threshold_search/handle.rs | 4 +- .../src/db/tuner/threshold_search/mod.rs | 5 +- .../src/db/tuner/threshold_search/search.rs | 4 +- crates/moon-core/src/db/tuner/ticks/deals.rs | 6 +- crates/moon-core/src/db/tuner/ticks/exit.rs | 124 +++-- crates/moon-core/src/db/tuner/ticks/line.rs | 352 +++++++++++++ .../src/db/tuner/ticks/line/tests.rs | 295 +++++++++++ crates/moon-core/src/db/tuner/ticks/mod.rs | 17 +- crates/moon-core/src/db/tuner/ticks/params.rs | 104 +++- crates/moon-core/src/db/tuner/ticks/search.rs | 361 +++++++++++++ .../src/db/tuner/ticks/search/tests.rs | 172 ++++++ crates/moon-core/src/db/tuner/ticks/stats.rs | 27 + .../src/db/tuner/ticks/stats/tests.rs | 36 ++ crates/moon-core/src/db/tuner/ticks/tests.rs | 30 +- .../src/db/tuner/ticks/tests/real_data.rs | 62 ++- crates/moon-core/src/db/tuner/ticks/verify.rs | 118 ++++- crates/moon-ui-gpui/src/analytics/bg.rs | 12 + crates/moon-ui-gpui/src/analytics/mod.rs | 6 + .../src/analytics/tuner/filter/mod.rs | 5 +- .../src/analytics/tuner/list/table.rs | 9 +- .../moon-ui-gpui/src/analytics/tuner/mod.rs | 40 +- .../src/analytics/tuner/shared.rs | 2 + .../moon-ui-gpui/src/analytics/tuner/shell.rs | 27 +- .../src/analytics/tuner/strat_columns.rs | 8 +- .../analytics/tuner/strat_columns/tests.rs | 32 ++ .../src/analytics/tuner/ticks/columns.rs | 126 +++++ .../src/analytics/tuner/ticks/fetch.rs | 198 +++++++ .../src/analytics/tuner/ticks/grid.rs | 229 ++++++++ .../src/analytics/tuner/ticks/load.rs | 375 +++++++++++++ .../src/analytics/tuner/ticks/mod.rs | 493 ++++++++++++++++++ .../src/analytics/tuner/ticks/rows.rs | 113 ++++ .../src/analytics/tuner/ticks/rows/tests.rs | 122 +++++ .../src/analytics/tuner/ticks/state.rs | 284 ++++++++++ crates/moon-ui-gpui/src/load_state.rs | 14 + .../tests/theme_contract/tuner.rs | 15 +- locales/analytics.yml | 146 ++++++ 41 files changed, 3884 insertions(+), 123 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/line.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/line/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/stats.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/stats/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index e24abcc1..d06d3cad 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -361,6 +361,10 @@ pub struct StratColsByMode { pub filter: u16, pub coins: u16, pub time: u16, + /// The "Entry/Exit" axis. Added after the key shipped, so a file saved before it has no + /// value here: `None` is "never chosen" and the UI substitutes the axis default, while a + /// saved `Some(0)` is the deliberate all-hidden mask the other three slots also allow. + pub ticks: Option, } impl Default for StratColsByMode { @@ -371,6 +375,7 @@ impl Default for StratColsByMode { filter: 0, coins: 0, time: 0, + ticks: None, } } } diff --git a/crates/moon-core/src/config/layout/tests.rs b/crates/moon-core/src/config/layout/tests.rs index 4de5a947..8bffeed7 100644 --- a/crates/moon-core/src/config/layout/tests.rs +++ b/crates/moon-core/src/config/layout/tests.rs @@ -849,6 +849,7 @@ fn current_strategy_column_masks_cannot_discard_the_saved_layout() { filter: 3, coins: 7, time: 11, + ticks: Some(13), }), ..WindowLayout::default() }; @@ -857,7 +858,20 @@ fn current_strategy_column_masks_cannot_discard_the_saved_layout() { let masks = decoded .analytics_strat_cols_modes2 .expect("current masks survive"); - assert_eq!((masks.filter, masks.coins, masks.time), (3, 7, 11)); + assert_eq!( + (masks.filter, masks.coins, masks.time, masks.ticks), + (3, 7, 11, Some(13)) + ); + // A file written before the fourth axis existed still loads: the missing slot reads `None`. + let older: WindowLayout = toml::from_str( + "analytics_strat_cols_modes2 = { filter = 3, coins = 7, time = 11 } +", + ) + .expect("a three-slot key must load"); + let masks = older + .analytics_strat_cols_modes2 + .expect("older masks survive"); + assert_eq!((masks.time, masks.ticks), (11, None)); for written in ["17", "true", "[1, 2]", "{ filter = \"bad\" }"] { let doc = format!( diff --git a/crates/moon-core/src/db/mod.rs b/crates/moon-core/src/db/mod.rs index d90f29fa..c57c940e 100644 --- a/crates/moon-core/src/db/mod.rs +++ b/crates/moon-core/src/db/mod.rs @@ -47,7 +47,7 @@ pub use quote::{ QuoteCurrency, QuoteScope, QuoteSpend, QuoteTotal, QuoteVolume, TradedVolume, UsdtTotal, ValuationCoverage, }; -pub use read_cancel::{ReadCancellation, with_read_cancellation}; +pub use read_cancel::{ReadCancellation, current_is_cancelled, with_read_cancellation}; pub use read_fail::{FailCode, FailKind, ReadFail, ReadResult}; pub use reader_budget::ReportReader; pub(crate) use rep::ReportStart; diff --git a/crates/moon-core/src/db/read_cancel.rs b/crates/moon-core/src/db/read_cancel.rs index 144fbc52..56e3db39 100644 --- a/crates/moon-core/src/db/read_cancel.rs +++ b/crates/moon-core/src/db/read_cancel.rs @@ -82,11 +82,14 @@ pub fn with_read_cancellation(cancellation: ReadCancellation, read: impl FnOn read() } -/// Whether the current thread's request token has been cancelled. +/// Whether the read this thread is running has been cancelled — for work that is not an +/// SQLite statement (a wait on another thread's answer, a per-row loop) and so never meets the +/// progress handler. /// /// Returns: -/// True when a token is installed and [`ReadCancellation::is_cancelled`]. -pub(super) fn current_is_cancelled() -> bool { +/// True when a token is installed and [`ReadCancellation::is_cancelled`]; `false` outside +/// any [`with_read_cancellation`] scope. +pub fn current_is_cancelled() -> bool { CURRENT.with(|current| { current .borrow() diff --git a/crates/moon-core/src/db/tuner/mod.rs b/crates/moon-core/src/db/tuner/mod.rs index 7f9a2ad3..aa1b6524 100644 --- a/crates/moon-core/src/db/tuner/mod.rs +++ b/crates/moon-core/src/db/tuner/mod.rs @@ -637,7 +637,7 @@ fn variant_stats_sql(src: &str, variants: &[Variant]) -> String { /// /// Returns: /// Complete KPI values for one variant. -fn stats_from_tally(tally: Tally, spent: f64) -> VarStats { +pub(crate) fn stats_from_tally(tally: Tally, spent: f64) -> VarStats { let mut stats = VarStats { n: tally.n, wins: tally.wins, diff --git a/crates/moon-core/src/db/tuner/threshold_search/handle.rs b/crates/moon-core/src/db/tuner/threshold_search/handle.rs index ecfc272f..a16bffb9 100644 --- a/crates/moon-core/src/db/tuner/threshold_search/handle.rs +++ b/crates/moon-core/src/db/tuner/threshold_search/handle.rs @@ -91,7 +91,7 @@ impl SearchHandle { } /// Record one finished restart. An abandoned restart never calls this. - pub(super) fn record_restart(&self) { + pub(crate) fn record_restart(&self) { self.0.completed.fetch_add(1, Ordering::Relaxed); } @@ -108,7 +108,7 @@ impl SearchHandle { } /// Mark that a unit of work was dropped unfinished. One-way, like cancellation. - pub(super) fn note_abandoned(&self) { + pub(crate) fn note_abandoned(&self) { self.0.abandoned.store(true, Ordering::Relaxed); } diff --git a/crates/moon-core/src/db/tuner/threshold_search/mod.rs b/crates/moon-core/src/db/tuner/threshold_search/mod.rs index 1ee79c10..c8f89ca7 100644 --- a/crates/moon-core/src/db/tuner/threshold_search/mod.rs +++ b/crates/moon-core/src/db/tuner/threshold_search/mod.rs @@ -12,7 +12,8 @@ mod compose; mod handle; -mod search; +/// The rayon pool and the per-restart seeds; shared with the tape search of `ticks`. +pub(crate) mod search; #[cfg(test)] mod tests; @@ -553,7 +554,7 @@ fn build_folds( /// /// A sample whose trades ALL share one timestamp has no boundary to snap to, and no later period /// to hold back — so it is not split at all. -fn train_split(closes: &[i64], frac: f64) -> usize { +pub(crate) fn train_split(closes: &[i64], frac: f64) -> usize { let n = closes.len(); if n < 2 || !frac.is_finite() || frac >= 1.0 { return n; diff --git a/crates/moon-core/src/db/tuner/threshold_search/search.rs b/crates/moon-core/src/db/tuner/threshold_search/search.rs index 59aabad4..020dee8d 100644 --- a/crates/moon-core/src/db/tuner/threshold_search/search.rs +++ b/crates/moon-core/src/db/tuner/threshold_search/search.rs @@ -145,7 +145,7 @@ fn pool() -> Option<&'static rayon::ThreadPool> { /// /// Returns: /// Whatever `f` returns. -pub(super) fn install(f: impl FnOnce() -> R + Send) -> R { +pub(crate) fn install(f: impl FnOnce() -> R + Send) -> R { match pool() { Some(pool) => pool.install(f), None => f(), @@ -160,7 +160,7 @@ pub(super) fn install(f: impl FnOnce() -> R + Send) -> R { /// /// The low bit is forced on because xorshift64* is degenerate on a zero state and the mix can /// legitimately produce zero. -pub(super) fn restart_seed(base: u64, restart: usize) -> u64 { +pub(crate) fn restart_seed(base: u64, restart: usize) -> u64 { // splitmix64, the standard companion mixer for seeding a weaker generator. let mut z = (base ^ restart as u64).wrapping_add(0x9E37_79B9_7F4A_7C15); z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index e7688ae8..839b4faf 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -26,7 +26,8 @@ pub struct DealsRead { /// The delta columns in the order [`Deltas`] is filled below; every one is a `FIELDS` column, /// so the unified source projects it (NULL when the replica lacks it). -const DELTA_COLS: [&str; 11] = [ +const DELTA_COLS: [&str; 12] = [ + "d5s", "d1m", "d5m", "d15m", @@ -107,7 +108,8 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { continue; } let mut deltas = Deltas::default(); - let slots: [&mut f64; 11] = [ + let slots: [&mut f64; 12] = [ + &mut deltas.d5s, &mut deltas.d1m, &mut deltas.d5m, &mut deltas.d15m, diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index 35f0ba53..be93914b 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -2,20 +2,21 @@ //! model for every strategy kind — after the entry filled, the exit of any strategy is a //! function of the sell-order rules and the tape. //! -//! Phase 1 carries the take-profit alone: `SellPrice` per cent above the fill, raised by -//! `MShotSellAtLastPrice` to the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the -//! 4-second-old ASK, i.e. before the spike"; the model reads the last print at least -//! [`PRE_SPIKE_LOOKBACK_MS`] before the fill, since the tape has no book). A position the take -//! never closed exits AS THE REPORT SAYS IT DID — [`ExitKind::Fact`] — which the caller shows as -//! "exit not modelled" rather than as a reproduction. The moving line (`PriceDown*`, -//! `SellLevel*`, `SellShot*`, `StopLoss`) is phase 2, checked against the archived Exit lines -//! before it is trusted. +//! The take-profit is `SellPrice` per cent above the fill, raised by `MShotSellAtLastPrice` to +//! the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the 4-second-old ASK, i.e. before +//! the spike"; the model reads the last print at least [`PRE_SPIKE_LOOKBACK_MS`] before the +//! fill, since the tape has no book). From there the line moves under the strategy's sell rules +//! — `PriceDown*`, `SellLevel*`, `SellShot*` — and the stop fires under `StopLoss*`; see +//! [`super::line`]. A position nothing closed inside the tape is [`ExitKind::OpenAtWindowEnd`]: +//! not a trade, whatever the core's exit was. -use super::mshot::PRE_SPIKE_LOOKBACK_MS; -use super::{Deal, Exit, ExitKind, Fill, reaches}; +use super::line::{LineWalk, walk}; +use super::mshot::{DEFAULT_LATENCY_MS, PRE_SPIKE_LOOKBACK_MS}; +use super::{Deal, Exit, Fill}; use crate::feed::types::Tick; -/// Sell-line parameters, in the strategy's own units. +/// Sell-line parameters, in the strategy's own units (per cent, seconds; `SellDelay` is ms). +/// Every rule's fields are documented in [`super::line`]. #[derive(Clone, Debug, PartialEq)] pub struct ExitParams { /// `SellPrice` — take-profit distance from the fill, per cent. @@ -27,15 +28,75 @@ pub struct ExitParams { /// `SellDelay` — milliseconds the core waits before placing the sell; prints inside the /// delay cannot fill it. pub sell_delay_ms: f64, + // PriceDown + pub price_down_timer_s: f64, + pub price_down_pct: f64, + pub price_down_delay_s: f64, + pub price_down_relative: bool, + pub price_down_allowed_drop_pct: f64, + // SellLevel + pub sell_level_delay_s: f64, + pub sell_level_delay_next_s: f64, + pub sell_level_time_s: f64, + pub sell_level_count: u32, + pub sell_level_adjust_pct: f64, + pub sell_level_relative: bool, + pub sell_level_allowed_drop_pct: f64, + pub sell_level_work_time_s: f64, + // SellShot + pub ignore_sell_shot: bool, + pub sell_shot_distance_pct: f64, + pub sell_shot_corridor_pct: f64, + pub sell_shot_calc_interval_s: f64, + pub sell_shot_raise_wait_s: f64, + pub sell_shot_replace_delay_s: f64, + pub sell_shot_price_down: f64, + pub sell_shot_price_down_delay_s: f64, + pub sell_shot_allowed_up_pct: f64, + pub sell_shot_allowed_down_pct: f64, + pub sell_shot_delay_s: f64, + // Stops + pub stop_loss_pct: f64, + pub stop_loss_delay_s: f64, + /// Model parameter: how long a replacement of the sell takes to reach the book. + pub latency_ms: f64, } impl Default for ExitParams { + /// A plain 1 % take, nothing moving it, no stop. fn default() -> Self { Self { sell_price_pct: 1.0, sell_at_last_price: false, sell_price_adjust_pct: 0.0, sell_delay_ms: 0.0, + price_down_timer_s: 0.0, + price_down_pct: 0.0, + price_down_delay_s: 0.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.0, + sell_level_delay_s: 0.0, + sell_level_delay_next_s: 0.0, + sell_level_time_s: 0.0, + sell_level_count: 0, + sell_level_adjust_pct: 0.0, + sell_level_relative: false, + sell_level_allowed_drop_pct: 0.0, + sell_level_work_time_s: 0.0, + ignore_sell_shot: true, + sell_shot_distance_pct: 0.0, + sell_shot_corridor_pct: 50.0, + sell_shot_calc_interval_s: 0.6, + sell_shot_raise_wait_s: 0.0, + sell_shot_replace_delay_s: 0.0, + sell_shot_price_down: 0.0, + sell_shot_price_down_delay_s: 0.0, + sell_shot_allowed_up_pct: 10.0, + sell_shot_allowed_down_pct: -100.0, + sell_shot_delay_s: 0.0, + stop_loss_pct: 0.0, + stop_loss_delay_s: 0.0, + latency_ms: DEFAULT_LATENCY_MS, } } } @@ -72,47 +133,20 @@ impl<'a> ExitModel<'a> { take } - /// Replay the tape after the fill. + /// Replay the tape after the fill: the take, the moving line, the stop. /// /// Args: /// deal: The report row — its side, and its own exit for the fallback. /// ticks: The window's prints, ascending. /// fill: The modelled (or factual) entry. pub fn exit(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> Exit { + self.walk(deal, ticks, fill).exit + } + + /// The same replay with every level the line stood at, for the archive comparison. + pub fn walk(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> LineWalk { let take = self.take_level(deal, ticks, fill); - let armed_at = fill.t_ms + self.params.sell_delay_ms.max(0.0) as i64; - for tick in ticks { - let t_ms = tick.time_ms as i64; - // A print at the fill's own millisecond is the fill itself, not the exit. - if t_ms <= armed_at || t_ms <= fill.t_ms { - continue; - } - let price = f64::from(tick.price); - // A long's take is reached from below by a print coming UP, a short's from above. - if price > 0.0 && reaches(price, take, deal.is_short) { - return Exit { - t_ms, - price: take, - kind: ExitKind::Take, - }; - } - } - // No rule of this phase closed it. The report's exit is the honest stand-in while the - // fill is the factual one; a modelled fill that differs from the fact makes the fact's - // exit a guess — the caller keeps that distinction (`Verdict::exit` is `None` here). - let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(fill.t_ms); - if deal.close_ms > fill.t_ms && deal.close_ms <= tail && deal.sell_price > 0.0 { - return Exit { - t_ms: deal.close_ms, - price: deal.sell_price, - kind: ExitKind::Fact, - }; - } - Exit { - t_ms: tail, - price: f64::NAN, - kind: ExitKind::OpenAtWindowEnd, - } + walk(deal, ticks, fill, take, self.params) } } diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs new file mode 100644 index 00000000..24546e4c --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/line.rs @@ -0,0 +1,352 @@ +//! The moving sell line: where the sell order stood at every moment after the fill, under the +//! rules of the strategy's "Sell order" and "Stops" tabs, and which print crossed it. +//! +//! From the Moonbot FAQ (`PriceDown*`, `SellLevel*`, `SellShot*`, `StopLoss*` answers) and the +//! live strategies (2026-09-20: 862 of 1 331 run `PriceDownTimer` 1 s with `PriceDownPercent` +//! 50 relative, `SellLevelDelay` is absent everywhere, `IgnoreSellShot` is on all but two): +//! +//! - **PriceDown** — `PriceDownTimer` seconds after the buy the sell is lowered by +//! `PriceDownPercent`: of the distance to the buy when `PriceDownRelative` (`sell −= (sell − +//! buy) · pct`), of the price otherwise (`sell −= buy · pct`); then again every +//! `PriceDownDelay` seconds (0 reads as the terminal's own 0.33 s floor), never below +//! `PriceDownAllowedDrop` per cent over the buy. A zero timer never starts. +//! - **SellLevel** — `SellLevelDelay` seconds after the buy (a negative value is the 0.33 s +//! floor, zero never), the sell moves to the highest print of the last `SellLevelTime` +//! seconds adjusted by `SellLevelAdjust` per cent — of that high, or of its distance to the +//! buy when `SellLevelRelative` — at most `SellLevelCount` times, every `SellLevelDelayNext` +//! (or `SellLevelDelay`) seconds, inside `SellLevelWorkTime`, never below +//! `SellLevelAllowedDrop` per cent over the buy. +//! - **SellShot** (`IgnoreSellShot` off, `SellShotDistance` non-zero) — after `SellShotDelay` +//! the sell keeps `SellShotDistance` per cent off the highest print of the last +//! `SellShotCalcInterval` seconds, re-placed when its distance leaves the corridor +//! `Distance · (1 ± Corridor/100)`: after `SellShotRaiseWait` when moving away from the buy, +//! after `SellShotReplaceDelay` when moving toward it; `SellShotPriceDown` narrows the +//! distance by that much per second past `SellShotPriceDownDelay`; the line stays between +//! `SellShotAllowedDown` and `SellShotAllowedUp` per cent over the buy. +//! - **StopLoss** — `StopLoss` per cent from the buy (negative: a loss), armed +//! `StopLossDelay` seconds after the buy; the first print through it is a market exit at +//! the print's own price. +//! +//! Every rule is written for a long and mirrored for a short by [`Side`]. A replacement reaches +//! the exchange `latency_ms` later, as the entry's does: a spike through the OLD level in that +//! gap fills there. The line's replacements are recorded so the model can be held against the +//! archived Exit line of the trade. + +use super::exit::ExitParams; +use super::mshot::FAST_ALGO_WINDOW_MS; +use super::{Deal, Exit, ExitKind, Fill, reaches}; +use crate::feed::types::Tick; + +/// The terminal's own floor on a step delay of zero: the FAQ's "0.33 s internal minimum". +pub const STEP_FLOOR_MS: i64 = 330; + +/// Which way the position profits, folding every "above/below the buy" into one sign. +#[derive(Clone, Copy)] +struct Side { + long: bool, +} + +impl Side { + /// `pct` per cent over the buy in the PROFIT direction: above for a long, below for a + /// short. + fn over(self, base: f64, pct: f64) -> f64 { + if self.long { + base * (1.0 + pct / 100.0) + } else { + base * (1.0 - pct / 100.0) + } + } + + /// The take side of two levels — the higher for a long — i.e. farther in profit. + fn farther(self, a: f64, b: f64) -> f64 { + if self.long { a.max(b) } else { a.min(b) } + } + + /// The nearer of two levels in profit terms. + fn nearer(self, a: f64, b: f64) -> f64 { + if self.long { a.min(b) } else { a.max(b) } + } + + /// The extreme print in the profit direction over a run. + fn extreme(self, prices: impl Iterator) -> Option { + if self.long { + prices.reduce(f64::max) + } else { + prices.reduce(f64::min) + } + } + + /// Distance of `level` from `reference`, per cent, positive in the profit direction. + fn distance_pct(self, reference: f64, level: f64) -> f64 { + if reference <= 0.0 { + return 0.0; + } + let signed = if self.long { + level - reference + } else { + reference - level + }; + signed / reference * 100.0 + } +} + +/// One replacement of the line, for the comparison with the archived Exit points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LinePoint { + pub t_ms: i64, + pub price: f64, +} + +/// The walk's answer: the exit and every level the line stood at, placement first. +#[derive(Clone, Debug, PartialEq)] +pub struct LineWalk { + pub exit: Exit, + pub points: Vec, +} + +/// Seconds to milliseconds, with the terminal's floor for a zero delay. +fn step_ms(seconds: f64) -> i64 { + let ms = (seconds * 1000.0) as i64; + if ms <= 0 { STEP_FLOOR_MS } else { ms } +} + +/// Walk the tape after the fill under `params`, starting from the take `take`. +/// +/// Args: +/// deal: The row — its side. +/// ticks: The window's prints, ascending. +/// fill: The entry. +/// take: The take level the line starts at (see `ExitModel::take_level`). +/// params: The sell-line rules. +pub fn walk(deal: &Deal, ticks: &[Tick], fill: Fill, take: f64, params: &ExitParams) -> LineWalk { + let side = Side { + long: deal.is_long(), + }; + let latency_ms = params.latency_ms.max(0.0) as i64; + let armed_at = fill.t_ms + params.sell_delay_ms.max(0.0) as i64; + let mut points = vec![LinePoint { + t_ms: armed_at, + price: take, + }]; + // The exchange's level (what fills) and the core's (what the rules move); a move the + // exchange has not seen yet is `pending`. + let mut exch_line = take; + let mut core_line = take; + let mut pending: Option<(i64, f64)> = None; + let mut place = |t_ms: i64, level: f64, core: &mut f64, pending: &mut Option<(i64, f64)>| { + if (level - *core).abs() <= f64::EPSILON * core.abs() { + return; + } + *core = level; + *pending = Some((t_ms + latency_ms, level)); + points.push(LinePoint { + t_ms: t_ms + latency_ms, + price: level, + }); + }; + + // --- PriceDown --- + let pd_on = params.price_down_timer_s > 0.0 && params.price_down_pct > 0.0; + let mut pd_next = if pd_on { + Some(fill.t_ms + (params.price_down_timer_s * 1000.0) as i64) + } else { + None + }; + let pd_floor = side.over(fill.price, params.price_down_allowed_drop_pct); + + // --- SellLevel --- + let sl_on = params.sell_level_delay_s != 0.0 + && params.sell_level_time_s > 0.0 + && params.sell_level_count > 0; + let sl_first_ms = if params.sell_level_delay_s < 0.0 { + STEP_FLOOR_MS + } else { + (params.sell_level_delay_s * 1000.0) as i64 + }; + let mut sl_next = if sl_on { + Some(fill.t_ms + sl_first_ms) + } else { + None + }; + let sl_step_ms = if params.sell_level_delay_next_s > 0.0 { + (params.sell_level_delay_next_s * 1000.0) as i64 + } else if params.sell_level_delay_next_s < 0.0 { + STEP_FLOOR_MS + } else { + sl_first_ms.max(STEP_FLOOR_MS) + }; + let mut sl_left = params.sell_level_count; + let sl_until = if params.sell_level_work_time_s > 0.0 { + Some(fill.t_ms + (params.sell_level_work_time_s * 1000.0) as i64) + } else { + None + }; + let sl_floor = side.over(fill.price, params.sell_level_allowed_drop_pct); + + // --- SellShot --- + let ss_on = !params.ignore_sell_shot && params.sell_shot_distance_pct != 0.0; + let ss_from = fill.t_ms + (params.sell_shot_delay_s.max(0.0) * 1000.0) as i64; + let ss_calc_ms = + ((params.sell_shot_calc_interval_s.max(0.0) * 1000.0) as i64).max(FAST_ALGO_WINDOW_MS); + let ss_low = side.over(fill.price, params.sell_shot_allowed_down_pct); + let ss_high = side.over(fill.price, params.sell_shot_allowed_up_pct); + // `(kind, since)`: which way the line is out of the corridor and since when. + let mut ss_breach: Option<(bool, i64)> = None; + + // --- StopLoss --- + let stop_on = params.stop_loss_pct != 0.0; + let stop_level = side.over(fill.price, params.stop_loss_pct); + let stop_from = fill.t_ms + (params.stop_loss_delay_s.max(0.0) * 1000.0) as i64; + + let mut last_t = fill.t_ms; + for (index, tick) in ticks.iter().enumerate() { + let t_ms = tick.time_ms as i64; + let price = f64::from(tick.price); + if t_ms <= fill.t_ms || !price.is_finite() || price <= 0.0 { + continue; + } + last_t = t_ms; + // The timer-driven rules moved the line at their own moments, between prints; every + // step due by this print happened BEFORE it, and a step that also reached the book + // before it is what this print meets. + // + // PriceDown steps, one per due moment. + while let Some(due) = pd_next.filter(|due| t_ms >= *due) { + let next = if params.price_down_relative { + core_line - (core_line - fill.price) * params.price_down_pct / 100.0 + } else { + core_line - side.over(fill.price, params.price_down_pct) + fill.price + }; + let next = side.farther(next, pd_floor); + if (next - core_line).abs() <= f64::EPSILON * core_line.abs() { + pd_next = None; + break; + } + place(due, next, &mut core_line, &mut pending); + pd_next = Some(due + step_ms(params.price_down_delay_s)); + } + // SellLevel: to the high of the look-back, adjusted. + while let Some(due) = sl_next.filter(|due| t_ms >= *due) { + if sl_left == 0 || sl_until.is_some_and(|until| due > until) { + sl_next = None; + break; + } + let from = due - (params.sell_level_time_s * 1000.0) as i64; + let high = side.extreme( + ticks[..=index] + .iter() + .filter(|t| { + let tt = t.time_ms as i64; + tt >= from && tt <= due && t.price > 0.0 + }) + .map(|t| f64::from(t.price)), + ); + if let Some(high) = high { + let next = if params.sell_level_relative { + fill.price + (high - fill.price) * params.sell_level_adjust_pct / 100.0 + } else { + side.over(high, params.sell_level_adjust_pct) + }; + let next = side.farther(next, sl_floor); + place(due, next, &mut core_line, &mut pending); + } + sl_left -= 1; + sl_next = Some(due + sl_step_ms); + } + if let Some((_, level)) = pending.filter(|(apply_at, _)| t_ms >= *apply_at) { + exch_line = level; + pending = None; + } + // The stop is a market order the core fires on the print; the sell is a limit the + // print reaches. Both come before the print-driven rule below moves anything. + if stop_on && t_ms >= stop_from && reaches(price, stop_level, side.long) { + return LineWalk { + exit: Exit { + t_ms, + price, + kind: ExitKind::Stop, + }, + points, + }; + } + if t_ms > armed_at && reaches(price, exch_line, !side.long) { + return LineWalk { + exit: Exit { + t_ms, + price: exch_line, + // What the print met: the take as placed, or a level a rule moved it to. + kind: if exch_line == take { + ExitKind::Take + } else { + ExitKind::Line + }, + }, + points, + }; + } + // SellShot: the line follows the market inside its corridor — driven by this print. + if ss_on && t_ms >= ss_from { + let from = t_ms - ss_calc_ms; + let reference = side.extreme( + ticks[..=index] + .iter() + .filter(|t| (t.time_ms as i64) >= from && t.price > 0.0) + .map(|t| f64::from(t.price)), + ); + if let Some(reference) = reference { + let elapsed_s = (t_ms - fill.t_ms) as f64 / 1000.0; + let mut distance = params.sell_shot_distance_pct; + if params.sell_shot_price_down < 0.0 { + let past = (elapsed_s - params.sell_shot_price_down_delay_s).max(0.0); + distance -= params.sell_shot_price_down.abs() * past; + } + let corridor = distance.abs() * params.sell_shot_corridor_pct / 100.0; + let d = side.distance_pct(reference, core_line); + let out = if d > distance + corridor { + Some(false) // too far from the market: move toward the buy + } else if d < distance - corridor { + Some(true) // too close: move away from the buy + } else { + None + }; + match out { + None => ss_breach = None, + Some(away) => { + let since = match ss_breach { + Some((seen, since)) if seen == away => since, + _ => { + ss_breach = Some((away, t_ms)); + t_ms + } + }; + let wait_ms = if away { + (params.sell_shot_raise_wait_s * 1000.0) as i64 + } else { + (params.sell_shot_replace_delay_s * 1000.0) as i64 + }; + if t_ms - since >= wait_ms { + let next = side.over(reference, distance); + let next = side.nearer(side.farther(next, ss_low), ss_high); + place(t_ms, next, &mut core_line, &mut pending); + ss_breach = None; + } + } + } + } + } + } + // Nothing closed it inside the tape. Not the report's own exit: a variant that never + // closes is not a trade, whatever the core's rules did, and the caption counts it. + let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(last_t); + LineWalk { + exit: Exit { + t_ms: tail, + price: f64::NAN, + kind: ExitKind::OpenAtWindowEnd, + }, + points, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs new file mode 100644 index 00000000..96d6c145 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -0,0 +1,295 @@ +//! The sell line's rules on synthetic tapes: one rule at a time, then the mirror. + +use super::*; +use crate::db::tuner::ticks::exit::ExitModel; +use crate::db::tuner::ticks::{Deltas, EntryParams, verify}; +use crate::feed::types::Side as TickSide; + +fn tick(t_ms: i64, price: f64) -> Tick { + Tick { + time_ms: t_ms as f64, + price: price as f32, + qty: 1.0, + side: TickSide::Buy, + } +} + +fn tape(points: &[(i64, f64)]) -> Vec { + points.iter().map(|&(t, p)| tick(t, p)).collect() +} + +fn deal(short: bool) -> Deal { + Deal { + report_uid: 1, + core_uid: 7, + strategy_id: 42, + kind: "MoonShot".into(), + coin: "ACE".into(), + buy_ms: 0, + close_ms: 60_000, + buy_price: 100.0, + sell_price: 100.5, + spent: 1_000.0, + is_short: short, + sell_reason: "Auto Price Down".into(), + fact_pnl: 5.0, + deltas: Deltas::default(), + tick: None, + } +} + +fn fill() -> Fill { + Fill { + t_ms: 0, + price: 100.0, + } +} + +/// A 1 % take, no latency, and the rule under test. +fn params() -> ExitParams { + ExitParams { + latency_ms: 0.0, + ..ExitParams::default() + } +} + +// ---- PriceDown ------------------------------------------------------------------------------- + +#[test] +fn price_down_steps_the_line_toward_the_buy_on_the_timer() { + // Take 101. Timer 1 s, then every 1 s, 50 % of the remaining distance (relative): 100.5 + // at t=1000, 100.25 at t=2000, floored at +0.1 % = 100.1. + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.1, + ..params() + }; + let ticks = tape(&[ + (500, 100.0), + (1_500, 100.0), + (2_500, 100.0), + (3_500, 100.0), + (4_500, 100.0), + (5_000, 100.2), + ]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + let levels: Vec = w.points.iter().map(|pt| pt.price).collect(); + assert!((levels[0] - 101.0).abs() < 1e-9); + assert!((levels[1] - 100.5).abs() < 1e-9, "{levels:?}"); + assert!((levels[2] - 100.25).abs() < 1e-9, "{levels:?}"); + assert!((levels[3] - 100.125).abs() < 1e-9, "{levels:?}"); + assert!((levels[4] - 100.1).abs() < 1e-9, "the floor: {levels:?}"); + assert_eq!(levels.len(), 5, "no step past the floor"); + // The print at 100.2 at t=5000 crosses the line at 100.1. + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 5_000)); + assert!((w.exit.price - 100.1).abs() < 1e-9); +} + +#[test] +fn price_down_absolute_takes_a_share_of_the_price() { + // SellPrice 1 %, pct 0.2 absolute: 101 -> 100.8 (of the buy price). + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 0.2, + price_down_relative: false, + price_down_allowed_drop_pct: 0.0, + ..params() + }; + let ticks = tape(&[(1_500, 100.0), (2_000, 100.9)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert!((w.points[1].price - 100.8).abs() < 1e-9, "{:?}", w.points); + assert_eq!(w.exit.kind, ExitKind::Line); +} + +#[test] +fn a_zero_timer_never_starts_the_steps() { + let p = ExitParams { + price_down_timer_s: 0.0, + price_down_pct: 50.0, + ..params() + }; + let ticks = tape(&[(5_000, 100.0), (60_000, 100.5)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert_eq!(w.points.len(), 1); + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd, "nothing closed it"); +} + +#[test] +fn a_zero_delay_steps_at_the_terminal_floor() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 0.0, + price_down_allowed_drop_pct: -1.0, + ..params() + }; + let ticks = tape(&[(1_000, 100.0), (1_660, 100.0)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + // t=1000, 1330, 1660: three steps by the second print. + assert_eq!(w.points.len(), 4, "{:?}", w.points); + assert_eq!(w.points[2].t_ms, 1_330); +} + +// ---- SellLevel ------------------------------------------------------------------------------- + +#[test] +fn sell_level_moves_to_the_look_back_high_adjusted() { + // Delay 2 s, look back 10 s, adjust -1 %: the high of the last 10 s at t=2000 is 103 -> + // 101.97; once (count 1). + let p = ExitParams { + sell_level_delay_s: 2.0, + sell_level_time_s: 10.0, + sell_level_count: 1, + sell_level_adjust_pct: -1.0, + sell_level_allowed_drop_pct: 0.0, + ..params() + }; + let ticks = tape(&[(500, 103.0), (1_000, 100.5), (2_000, 100.5), (3_000, 102.0)]); + let w = walk(&deal(false), &ticks, fill(), 105.0, &p); + assert!((w.points[1].price - 101.97).abs() < 1e-9, "{:?}", w.points); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 3_000)); +} + +#[test] +fn sell_level_relative_takes_a_share_of_the_distance_to_the_buy() { + let p = ExitParams { + sell_level_delay_s: 1.0, + sell_level_time_s: 10.0, + sell_level_count: 1, + sell_level_adjust_pct: 50.0, + sell_level_relative: true, + ..params() + }; + let ticks = tape(&[(500, 110.0), (1_000, 100.0)]); + let w = walk(&deal(false), &ticks, fill(), 120.0, &p); + assert!((w.points[1].price - 105.0).abs() < 1e-9, "{:?}", w.points); +} + +// ---- SellShot --------------------------------------------------------------------------------- + +#[test] +fn sell_shot_follows_the_market_inside_its_corridor() { + // Distance 1 %, corridor 50 %: the line stays while 0.5-1.5 % off the reference. The + // take at 101 is 1 % off 100; a rise to 100.8 leaves it 0.2 % off -> too close -> after + // the raise wait (0) it moves away to 101.808. + let p = ExitParams { + ignore_sell_shot: false, + sell_shot_distance_pct: 1.0, + sell_shot_corridor_pct: 50.0, + sell_shot_calc_interval_s: 0.1, + sell_shot_allowed_up_pct: 10.0, + sell_shot_allowed_down_pct: -1.0, + ..params() + }; + let ticks = tape(&[(500, 100.0), (1_000, 100.8), (1_500, 101.5)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert!((w.points[1].price - 101.808).abs() < 1e-4, "{:?}", w.points); + // 101.5 stays under the moved line, and the tape ends before the report's close. + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd); +} + +#[test] +fn sell_shot_is_capped_by_allowed_up() { + let p = ExitParams { + ignore_sell_shot: false, + sell_shot_distance_pct: 1.0, + sell_shot_corridor_pct: 50.0, + sell_shot_calc_interval_s: 0.1, + sell_shot_allowed_up_pct: 0.5, + sell_shot_allowed_down_pct: -1.0, + ..params() + }; + let ticks = tape(&[(500, 100.0), (1_000, 100.8)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert!((w.points[1].price - 100.5).abs() < 1e-4, "{:?}", w.points); +} + +// ---- StopLoss --------------------------------------------------------------------------------- + +#[test] +fn the_stop_fires_on_the_print_after_its_delay() { + let p = ExitParams { + stop_loss_pct: -1.0, + stop_loss_delay_s: 2.0, + ..params() + }; + // A print through the stop inside the delay does not fire; one after it does, at the + // print's price (a market exit). + let ticks = tape(&[(1_000, 98.5), (3_000, 98.7)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 3_000)); + assert!((w.exit.price - 98.7).abs() < 1e-4); +} + +// ---- the mirror and the archive ------------------------------------------------------------- + +#[test] +fn a_short_mirrors_every_rule() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_allowed_drop_pct: 0.1, + stop_loss_pct: -1.0, + ..params() + }; + // Take 99 (1 % below the buy at 100); one step -> 99.5 at t=1000; a print down to 99.4 + // before the next step crosses it. + let ticks = tape(&[(1_500, 100.0), (1_900, 99.4)]); + let w = walk(&deal(true), &ticks, fill(), 99.0, &p); + assert!((w.points[1].price - 99.5).abs() < 1e-9, "{:?}", w.points); + assert_eq!(w.exit.kind, ExitKind::Line); + assert!((w.exit.price - 99.5).abs() < 1e-9); + // The stop is ABOVE a short's buy. + let ticks = tape(&[(500, 101.2)]); + let w = walk(&deal(true), &ticks, fill(), 99.0, &p); + assert_eq!(w.exit.kind, ExitKind::Stop); +} + +#[test] +fn a_spike_through_the_old_level_fills_before_a_step_lands() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + latency_ms: 100.0, + ..params() + }; + // The step is decided at t=1000 and reaches the book at t=1100; a print at 101 at t=1050 + // fills the old take at 101, not the new line at 100.5. + let ticks = tape(&[(1_000, 100.0), (1_050, 101.0)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert_eq!(w.exit.kind, ExitKind::Take); + assert!((w.exit.price - 101.0).abs() < 1e-9); +} + +#[test] +fn verify_holds_the_line_against_the_archived_points() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_allowed_drop_pct: 0.1, + ..params() + }; + let mut d = deal(false); + d.sell_price = 100.25; + let ticks = tape(&[(0, 100.0), (1_500, 100.0), (2_500, 100.3)]); + // The archive's points are what the core did: placed at 101, 100.5 at 1 s, 100.25 at 2 s. + let archived = [(0, 101.0), (1_000, 100.5), (2_000, 100.25)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&archived)); + assert_eq!(v.exit_kind, Some(ExitKind::Line)); + assert_eq!(v.exit, Some(true), "{v:?}"); + assert_eq!(v.line_points, Some((3, 3))); + // A line the core moved differently - a point the model never re-placed at - is a miss + // even when the exit price agrees. + let other = [(0, 101.0), (1_000, 100.7), (2_000, 100.25)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&other)); + assert_eq!(v.exit, Some(false)); + assert_eq!(v.line_points, Some((2, 3))); + // The same walk through `ExitModel`. + let w = ExitModel::new(&p).walk(&d, &ticks, fill()); + assert_eq!(w.points.len(), 3); +} diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index ae553d41..1fad2a1c 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -27,8 +27,11 @@ use crate::feed::types::Tick; pub mod deals; pub mod entry; pub mod exit; +pub mod line; pub mod mshot; pub mod params; +pub mod search; +pub mod stats; pub mod verify; pub use deals::{DealsRead, read_deals}; @@ -36,6 +39,8 @@ pub use entry::{EntryModel, entry_model_for}; pub use exit::{ExitModel, ExitParams}; pub use mshot::{MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; +pub use search::{PreparedDeal, SearchParams, SearchResult, suggest, variant_tally}; +pub use stats::fact_stats; pub use verify::{Verdict, verify}; /// Relative tolerance under which a modelled price counts as reproducing the fact: 0.05 %. @@ -72,6 +77,8 @@ pub fn reaches(price: f64, level: f64, from_below: bool) -> bool { /// delta can. All values are per cent, exactly as `orders_rep` stores them. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Deltas { + /// The last five seconds' move (`d5s`) — the spike itself; shown, not a modifier input. + pub d5s: f64, pub d1m: f64, pub d5m: f64, pub d15m: f64, @@ -147,15 +154,13 @@ pub struct Fill { pub enum ExitKind { /// A print reached the take-profit level. Take, - /// A print crossed the moving sell line (PriceDown / SellLevel / SellShot) — phase 2. + /// A print crossed the moving sell line (PriceDown / SellLevel / SellShot). Line, - /// The stop-loss level was crossed — phase 2. + /// The stop-loss level was crossed: a market exit at the print. Stop, - /// No exit rule decided; the exit is the report's own (`sellprice` at `closedatems`). A - /// phase-1 placeholder the caller shows as "exit not modelled". - Fact, /// Nothing closed the position before the tape ran out. Not a trade: excluded from the KPI - /// and counted in the caption. + /// and counted in the caption. Under the strategy's own parameters this is the model + /// failing to reproduce an exit the core made, and the verdict says so. OpenAtWindowEnd, } diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 0feb11ee..ba62fb0b 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -70,6 +70,32 @@ const GRID_SELL_PRICE: &[f64] = &[ 5.0, ]; const GRID_SELL_DELAY_MS: &[f64] = &[0.0, 100.0, 250.0, 500.0, 1000.0]; +const GRID_PD_TIMER_S: &[f64] = &[ + 0.0, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0, +]; +const GRID_PD_PCT: &[f64] = &[ + 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 70.0, 80.0, 90.0, 100.0, +]; +const GRID_PD_DELAY_S: &[f64] = &[0.0, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 30.0, 60.0]; +const GRID_DROP: &[f64] = &[ + -1.0, -0.5, -0.2, -0.1, 0.0, 0.01, 0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, +]; +const GRID_SL_DELAY_S: &[f64] = &[0.0, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0]; +const GRID_SL_TIME_S: &[f64] = &[0.0, 60.0, 300.0, 900.0, 1800.0, 3600.0, 7200.0]; +const GRID_SL_COUNT: &[f64] = &[0.0, 1.0, 2.0, 3.0, 5.0, 10.0]; +const GRID_SS_DISTANCE: &[f64] = &[ + 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0, 1.25, 1.5, 2.0, +]; +const GRID_SS_CORRIDOR: &[f64] = &[10.0, 25.0, 50.0, 75.0, 90.0]; +const GRID_SS_INTERVAL_S: &[f64] = &[0.2, 0.4, 0.6, 1.0, 2.0, 5.0, 10.0, 25.0]; +const GRID_SS_WAIT_S: &[f64] = &[0.0, 0.1, 0.2, 0.5, 1.0, 2.0]; +const GRID_SS_BOUND: &[f64] = &[ + -1.0, -0.5, -0.2, -0.1, 0.0, 0.2, 0.4, 0.5, 1.0, 2.0, 5.0, 10.0, +]; +const GRID_STOP: &[f64] = &[ + -10.0, -7.0, -5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.75, -0.5, -0.3, -0.2, -0.1, +]; +const GRID_STOP_DELAY_S: &[f64] = &[0.0, 1.0, 2.0, 4.0, 6.0, 10.0, 20.0, 30.0]; /// Every parameter of the axis, grid order: the Entry group first, then Exit. pub const TICK_PARAMS: &[TickParam] = &[ @@ -219,8 +245,52 @@ pub const TICK_PARAMS: &[TickParam] = &[ }, kinds: ANY, }, + exit_num("PriceDownTimer", GRID_PD_TIMER_S), + exit_num("PriceDownPercent", GRID_PD_PCT), + exit_num("PriceDownDelay", GRID_PD_DELAY_S), + exit_bool("PriceDownRelative"), + exit_num("PriceDownAllowedDrop", GRID_DROP), + exit_num("SellLevelDelay", GRID_SL_DELAY_S), + exit_num("SellLevelDelayNext", GRID_SL_DELAY_S), + exit_num("SellLevelTime", GRID_SL_TIME_S), + exit_num("SellLevelCount", GRID_SL_COUNT), + exit_num("SellLevelAdjust", GRID_DROP), + exit_bool("SellLevelRelative"), + exit_num("SellLevelAllowedDrop", GRID_DROP), + exit_num("SellLevelWorkTime", GRID_SL_TIME_S), + exit_bool("IgnoreSellShot"), + exit_num("SellShotDistance", GRID_SS_DISTANCE), + exit_num("SellShotCorridor", GRID_SS_CORRIDOR), + exit_num("SellShotCalcInterval", GRID_SS_INTERVAL_S), + exit_num("SellShotRaiseWait", GRID_SS_WAIT_S), + exit_num("SellShotReplaceDelay", GRID_SS_WAIT_S), + exit_num("SellShotAllowedUp", GRID_SS_BOUND), + exit_num("SellShotAllowedDown", GRID_SS_BOUND), + exit_num("SellShotDelay", GRID_SS_WAIT_S), + exit_num("StopLoss", GRID_STOP), + exit_num("StopLossDelay", GRID_STOP_DELAY_S), ]; +/// A numeric field of the Exit group every kind understands. +const fn exit_num(key: &'static str, grid: &'static [f64]) -> TickParam { + TickParam { + key, + group: ParamGroup::Exit, + kind: ParamKind::Num { grid }, + kinds: ANY, + } +} + +/// A boolean field of the Exit group every kind understands. +const fn exit_bool(key: &'static str) -> TickParam { + TickParam { + key, + group: ParamGroup::Exit, + kind: ParamKind::Bool, + kinds: ANY, + } +} + /// The parameters of one group that a kind's grid shows. pub fn params_for<'k>( group: ParamGroup, @@ -312,7 +382,7 @@ pub fn mshot_params(v: &StrategyValues<'_>, latency_ms: f64) -> MshotParams { } } -/// Sell-line parameters out of a strategy's values. +/// Sell-line parameters out of a strategy's values; `latency_ms` is the model's own. pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { let base = ExitParams::default(); ExitParams { @@ -320,5 +390,37 @@ pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { sell_at_last_price: v.bool("MShotSellAtLastPrice", base.sell_at_last_price), sell_price_adjust_pct: v.num("MShotSellPriceAdjust", base.sell_price_adjust_pct), sell_delay_ms: v.num("SellDelay", base.sell_delay_ms), + price_down_timer_s: v.num("PriceDownTimer", base.price_down_timer_s), + price_down_pct: v.num("PriceDownPercent", base.price_down_pct), + price_down_delay_s: v.num("PriceDownDelay", base.price_down_delay_s), + price_down_relative: v.bool("PriceDownRelative", base.price_down_relative), + price_down_allowed_drop_pct: v + .num("PriceDownAllowedDrop", base.price_down_allowed_drop_pct), + sell_level_delay_s: v.num("SellLevelDelay", base.sell_level_delay_s), + sell_level_delay_next_s: v.num("SellLevelDelayNext", base.sell_level_delay_next_s), + sell_level_time_s: v.num("SellLevelTime", base.sell_level_time_s), + sell_level_count: v + .num("SellLevelCount", f64::from(base.sell_level_count)) + .max(0.0) as u32, + sell_level_adjust_pct: v.num("SellLevelAdjust", base.sell_level_adjust_pct), + sell_level_relative: v.bool("SellLevelRelative", base.sell_level_relative), + sell_level_allowed_drop_pct: v + .num("SellLevelAllowedDrop", base.sell_level_allowed_drop_pct), + sell_level_work_time_s: v.num("SellLevelWorkTime", base.sell_level_work_time_s), + ignore_sell_shot: v.bool("IgnoreSellShot", base.ignore_sell_shot), + sell_shot_distance_pct: v.num("SellShotDistance", base.sell_shot_distance_pct), + sell_shot_corridor_pct: v.num("SellShotCorridor", base.sell_shot_corridor_pct), + sell_shot_calc_interval_s: v.num("SellShotCalcInterval", base.sell_shot_calc_interval_s), + sell_shot_raise_wait_s: v.num("SellShotRaiseWait", base.sell_shot_raise_wait_s), + sell_shot_replace_delay_s: v.num("SellShotReplaceDelay", base.sell_shot_replace_delay_s), + sell_shot_price_down: v.num("SellShotPriceDown", base.sell_shot_price_down), + sell_shot_price_down_delay_s: v + .num("SellShotPriceDownDelay", base.sell_shot_price_down_delay_s), + sell_shot_allowed_up_pct: v.num("SellShotAllowedUp", base.sell_shot_allowed_up_pct), + sell_shot_allowed_down_pct: v.num("SellShotAllowedDown", base.sell_shot_allowed_down_pct), + sell_shot_delay_s: v.num("SellShotDelay", base.sell_shot_delay_s), + stop_loss_pct: v.num("StopLoss", base.stop_loss_pct), + stop_loss_delay_s: v.num("StopLossDelay", base.stop_loss_delay_s), + latency_ms: base.latency_ms, } } diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs new file mode 100644 index 00000000..0b9d7a9c --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -0,0 +1,361 @@ +//! The search of the "Entry/Exit" axis: coordinate descent with random restarts over the +//! discrete grids of [`TICK_PARAMS`], scoring a point by REPLAYING every covered deal under +//! it — the same shape as `threshold_search`, with the SQL mask replaced by [`simulate`]. +//! +//! A point is a set of strategy values in the strategy's own spelling, laid over the values +//! the strategies hold now; the model's parameter structs are built from that overlay through +//! the same builders the grid's "now" column uses, so what the search varies is exactly what +//! Save writes. Only the fields of the groups the caller switched on are searched, minus the +//! ones it locked. +//! +//! The objective is the total money result over the fitted deals with at least `min_n` of them +//! still trading, ties broken by the profit factor — `metrics::Tally`, the same figures the KPI +//! matrix prints. A deal the variant never fills, or never closes inside its tape, is not a +//! trade and drops out of `n`; the caller prints "by N of M" beside the column so a variant +//! that wins by trading less is visible as such. +//! +//! Chronological order is kept on purpose: the train/holdout cut and the drawdown read the +//! SEQUENCE, and the deals arrive sorted by close from `read_deals`. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use rayon::prelude::*; + +use super::params::{ + ParamGroup, ParamKind, StrategyValues, TICK_PARAMS, exit_params, mshot_params, +}; +use super::{Deal, EntryParams, ExitParams, entry_model_for, simulate}; +use crate::db::metrics::Tally; +use crate::db::tuner::threshold_search::search::{install, restart_seed}; +use crate::db::tuner::threshold_search::{SearchHandle, train_split}; +use crate::feed::types::Tick; + +/// Passes of coordinate descent one restart may take before it is called converged. +const MAX_PASSES: usize = 16; + +/// One deal with its tape, ready to be replayed as often as the search asks. +#[derive(Clone)] +pub struct PreparedDeal { + pub deal: Deal, + /// The window's prints, ascending; shared, never copied per evaluation. + pub ticks: Arc<[Tick]>, + /// The archived first point of the entry line, when the archive holds it. + pub entry_start: Option<(i64, f64)>, +} + +/// What one search varies and how. +pub struct SearchParams<'a> { + /// The values every selected strategy holds now, in strategy spelling — the base every + /// point is laid over. Fields the strategies disagree on are absent and read as default. + pub base: &'a HashMap, + /// Schema defaults for the keys the base leaves out. + pub defaults: &'a HashMap, + /// The strategy kind of the sample, for the fields the grid offers. + pub kind: &'a str, + /// Whether the Entry group is searched (only when the kind has an entry model). + pub vary_entry: bool, + /// Whether the Exit group is searched. + pub vary_exit: bool, + /// Field keys held at the base value. + pub locked: &'a HashSet, + /// Restart count, at least 1. + pub restarts: usize, + /// Minimum trades a point must keep, or one tenth of the fitted sample. + pub min_n: Option, + /// Base seed of the restarts; `None` draws one from the clock. + pub seed: Option, + /// Share of the period, oldest first, the search may fit on. + pub train_frac: f64, + /// Replacement latency of the model, milliseconds. + pub latency_ms: f64, +} + +/// What the search found. +#[derive(Clone, Debug)] +pub struct SearchResult { + /// The winning values, in strategy spelling — only the fields that moved off the base. + pub values: Vec<(String, String)>, + /// What they achieve on the deals they were fitted on. + pub train: Tally, + /// What they achieve on the deals held back, when any were. + pub holdout: Option, + /// The seed the restarts were derived from. + pub seed: u64, +} + +/// One point of the grid: the varied fields' values, in strategy spelling. +type Point = HashMap<&'static str, String>; + +/// The model parameters a point comes to. +fn params_of( + base: &HashMap, + defaults: &HashMap, + point: &Point, + kind: &str, + latency_ms: f64, +) -> (EntryParams, ExitParams) { + let mut values = base.clone(); + for (key, value) in point { + values.insert((*key).to_string(), value.clone()); + } + let sv = StrategyValues { + values: &values, + defaults, + }; + let entry = if entry_model_for(kind) { + EntryParams::MoonShot(mshot_params(&sv, latency_ms)) + } else { + EntryParams::Fact + }; + let mut exit = exit_params(&sv); + exit.latency_ms = latency_ms; + (entry, exit) +} + +/// The spelling of one grid value in the strategy's format. +fn spell(kind: &ParamKind, index: usize) -> String { + match kind { + ParamKind::Num { grid } => { + let v = grid[index]; + if v.fract() == 0.0 { + format!("{v:.0}") + } else { + format!("{v}") + } + } + ParamKind::Bool => (if index == 0 { "NO" } else { "YES" }).to_string(), + ParamKind::Enum(options) => options[index].to_string(), + } +} + +/// How many values a field's grid offers. +fn arity(kind: &ParamKind) -> usize { + match kind { + ParamKind::Num { grid } => grid.len(), + ParamKind::Bool => 2, + ParamKind::Enum(options) => options.len(), + } +} + +/// The fields one search varies. +fn varied<'a>(p: &SearchParams<'a>) -> Vec<&'static super::params::TickParam> { + TICK_PARAMS + .iter() + .filter(|f| match f.group { + ParamGroup::Entry => p.vary_entry, + ParamGroup::Exit => p.vary_exit, + }) + .filter(|f| f.kinds.is_empty() || f.kinds.contains(&p.kind)) + .filter(|f| !p.locked.contains(f.key)) + .collect() +} + +/// The tally of a point over `deals`, in order. +fn tally(deals: &[PreparedDeal], entry: &EntryParams, exit: &ExitParams) -> Tally { + // The replay of every deal is independent; the tally is folded in order afterwards. + let results: Vec> = deals + .par_iter() + .map(|d| simulate(&d.deal, &d.ticks, entry, exit, d.entry_start).profit_money(&d.deal)) + .collect(); + let mut tally = Tally::default(); + for money in results.into_iter().flatten() { + tally.push(money); + } + tally +} + +/// Whether `a` beats `b` under the objective, with the sample floor. +fn better(a: &Tally, b: &Tally, min_n: i64) -> bool { + let a_ok = a.n >= min_n; + let b_ok = b.n >= min_n; + if a_ok != b_ok { + return a_ok; + } + if a.profit != b.profit { + return a.profit > b.profit; + } + a.profit_factor() > b.profit_factor() +} + +/// xorshift64*, the same stream shape the threshold search draws its starts from. +fn next_random(state: &mut u64) -> u64 { + let mut x = *state; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + *state = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) +} + +/// Run the search. +/// +/// Args: +/// deals: The covered deals, chronological by close. +/// params: What to vary and how hard to look. +/// handle: Stop and progress; a fresh one per run. +/// +/// Returns: +/// The best point found, or `None` when the sample is empty, nothing is varied, or the +/// run was stopped before its first restart finished. +pub fn suggest( + deals: &[PreparedDeal], + params: &SearchParams<'_>, + handle: &SearchHandle, +) -> Option { + let fields = varied(params); + if deals.is_empty() || fields.is_empty() { + return None; + } + let closes: Vec = deals.iter().map(|d| d.deal.close_ms).collect(); + let train_n = train_split(&closes, params.train_frac); + let train = &deals[..train_n]; + let min_n = params + .min_n + .unwrap_or_else(|| (train_n as i64 / 10).max(1)) + .max(1); + let seed = params.seed.unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(1) + | 1 + }); + let restarts = params.restarts.max(1); + let evaluate = |point: &Point| -> Tally { + let (entry, exit) = params_of( + params.base, + params.defaults, + point, + params.kind, + params.latency_ms, + ); + tally(train, &entry, &exit) + }; + let best = install(|| { + (0..restarts) + .into_par_iter() + .map(|restart| { + if handle.is_cancelled() { + handle.note_abandoned(); + return None; + } + // Restart 0 starts from the base itself; the others from a random grid point + // per varied field, so the descent is not trapped in the base's own valley. + let mut point = Point::new(); + if restart > 0 { + let mut state = restart_seed(seed, restart); + for field in &fields { + let index = (next_random(&mut state) % arity(&field.kind) as u64) as usize; + point.insert(field.key, spell(&field.kind, index)); + } + } + let mut score = evaluate(&point); + for _ in 0..MAX_PASSES { + let mut improved = false; + for field in &fields { + if handle.is_cancelled() { + handle.note_abandoned(); + return None; + } + let mut current = point.get(field.key).cloned(); + for index in 0..arity(&field.kind) { + let candidate = spell(&field.kind, index); + if current.as_deref() == Some(candidate.as_str()) { + continue; + } + point.insert(field.key, candidate.clone()); + let trial = evaluate(&point); + if better(&trial, &score, min_n) { + score = trial; + improved = true; + // The accepted value is what a rejected later candidate + // restores to. + current = Some(candidate); + } else { + match ¤t { + Some(c) => { + point.insert(field.key, c.clone()); + } + None => { + point.remove(field.key); + } + } + } + } + // A field that moved may enable a better value of one visited before, + // hence the passes; within one pass every field is visited once. + } + if !improved { + break; + } + } + handle.record_restart(); + Some((restart, point, score)) + }) + .flatten() + // Among equal scores the LOWEST restart wins, so the parallel fan-out answers as a + // sequential run would. + .reduce_with(|a, b| { + if better(&b.2, &a.2, min_n) || (!better(&a.2, &b.2, min_n) && b.0 < a.0) { + b + } else { + a + } + }) + })?; + let (_, point, train_tally) = best; + // The base's own spelling of a field is not a change; only what moved is reported. + let mut values: Vec<(String, String)> = point + .iter() + .filter(|(key, value)| params.base.get(**key) != Some(*value)) + .map(|(key, value)| ((*key).to_string(), value.clone())) + .collect(); + values.sort(); + let holdout = (train_n < deals.len()).then(|| { + let (entry, exit) = params_of( + params.base, + params.defaults, + &point, + params.kind, + params.latency_ms, + ); + tally(&deals[train_n..], &entry, &exit) + }); + Some(SearchResult { + values, + train: train_tally, + holdout, + seed, + }) +} + +/// The KPI of one explicit set of values over `deals` — a variant column. +/// +/// Args: +/// deals: The covered deals, chronological. +/// base: The strategies' current values. +/// defaults: Schema defaults. +/// kind: The strategy kind. +/// values: The variant's changes over the base, in strategy spelling. +/// latency_ms: Replacement latency of the model. +pub fn variant_tally( + deals: &[PreparedDeal], + base: &HashMap, + defaults: &HashMap, + kind: &str, + values: &[(String, String)], + latency_ms: f64, +) -> Tally { + let mut point = Point::new(); + for (key, value) in values { + if let Some(field) = TICK_PARAMS.iter().find(|f| f.key == key) { + point.insert(field.key, value.clone()); + } + } + let (entry, exit) = params_of(base, defaults, &point, kind, latency_ms); + install(|| tally(deals, &entry, &exit)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs new file mode 100644 index 00000000..d85ee36a --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -0,0 +1,172 @@ +//! The search on a synthetic sample where the right answer is known. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use super::*; +use crate::db::tuner::ticks::Deltas; +use crate::feed::types::Side; + +fn tick(t_ms: i64, price: f64) -> Tick { + Tick { + time_ms: t_ms as f64, + price: price as f32, + qty: 1.0, + side: Side::Buy, + } +} + +/// A Spread deal (entry from the fact) bought at 100 whose tape peaks at `peak` after the +/// fill, then falls back to the fact's exit. +fn prepared(uid: i64, peak: f64) -> PreparedDeal { + let deal = Deal { + report_uid: uid, + core_uid: 1, + strategy_id: 1, + kind: "Spread".into(), + coin: "ACE".into(), + buy_ms: 1_000 * uid, + close_ms: 1_000 * uid + 900, + buy_price: 100.0, + sell_price: 100.2, + spent: 1_000.0, + is_short: false, + sell_reason: "Sell Price".into(), + fact_pnl: 2.0, + deltas: Deltas::default(), + tick: None, + }; + let t0 = deal.buy_ms; + let ticks: Vec = vec![ + tick(t0 - 500, 100.0), + tick(t0, 100.0), + tick(t0 + 300, peak), + tick(t0 + 600, 100.2), + tick(t0 + 900, 100.2), + ]; + PreparedDeal { + deal, + ticks: Arc::from(ticks), + entry_start: None, + } +} + +fn base() -> HashMap { + [("SellPrice", "0.2"), ("StopLoss", "0")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +#[test] +fn the_search_raises_the_take_to_what_every_tape_reaches() { + // Every deal peaks at 101.0: a take of 1 % fills on all of them; 1.2 % on none. + let deals: Vec = (1..=8).map(|uid| prepared(uid, 101.0)).collect(); + let base = base(); + let defaults = HashMap::new(); + let mut locked: HashSet = TICK_PARAMS + .iter() + .filter(|f| f.group == ParamGroup::Exit) + .map(|f| f.key.to_string()) + .collect(); + locked.remove("SellPrice"); + let params = SearchParams { + base: &base, + defaults: &defaults, + kind: "Spread", + vary_entry: false, + vary_exit: true, + locked: &locked, + restarts: 3, + min_n: Some(4), + seed: Some(7), + train_frac: 1.0, + latency_ms: 0.0, + }; + let handle = SearchHandle::new(); + let result = suggest(&deals, ¶ms, &handle).expect("a result"); + assert_eq!( + result.values, + vec![("SellPrice".to_string(), "1".to_string())], + "{result:?}" + ); + assert_eq!(result.train.n, 8); + assert!( + (result.train.profit - 80.0).abs() < 1e-6, + "{}", + result.train.profit + ); + assert!(result.holdout.is_none()); + assert_eq!(handle.completed(), 3); + // The same values through the variant column. + let tally = variant_tally(&deals, &base, &defaults, "Spread", &result.values, 0.0); + assert!((tally.profit - 80.0).abs() < 1e-6); +} + +#[test] +fn the_holdout_is_scored_but_never_fitted_on() { + // The first six deals peak at 101, the last two at 100.5: fitted on the first 75 %, the + // search picks 1 %, which the holdout then fails to reach. + let deals: Vec = (1..=6) + .map(|uid| prepared(uid, 101.0)) + .chain((7..=8).map(|uid| prepared(uid, 100.5))) + .collect(); + let base = base(); + let defaults = HashMap::new(); + let mut locked: HashSet = TICK_PARAMS.iter().map(|f| f.key.to_string()).collect(); + locked.remove("SellPrice"); + let params = SearchParams { + base: &base, + defaults: &defaults, + kind: "Spread", + vary_entry: false, + vary_exit: true, + locked: &locked, + restarts: 1, + min_n: Some(3), + seed: Some(1), + train_frac: 0.75, + latency_ms: 0.0, + }; + let handle = SearchHandle::new(); + let result = suggest(&deals, ¶ms, &handle).expect("a result"); + assert_eq!(result.values[0].1, "1"); + assert_eq!(result.train.n, 6); + let holdout = result.holdout.expect("a holdout"); + assert_eq!(holdout.n, 0, "neither held-back deal reaches 1 %"); +} + +#[test] +fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { + let deals: Vec = (1..=3).map(|uid| prepared(uid, 101.0)).collect(); + let base = base(); + let defaults = HashMap::new(); + let all: HashSet = TICK_PARAMS.iter().map(|f| f.key.to_string()).collect(); + let params = SearchParams { + base: &base, + defaults: &defaults, + kind: "Spread", + vary_entry: true, + vary_exit: true, + locked: &all, + restarts: 2, + min_n: None, + seed: Some(1), + train_frac: 1.0, + latency_ms: 0.0, + }; + let handle = SearchHandle::new(); + assert!( + suggest(&deals, ¶ms, &handle).is_none(), + "everything locked" + ); + let none: HashSet = HashSet::new(); + let params = SearchParams { + locked: &none, + ..params + }; + let handle = SearchHandle::new(); + handle.cancel(); + assert!(suggest(&deals, ¶ms, &handle).is_none()); + assert!(handle.abandoned()); +} diff --git a/crates/moon-core/src/db/tuner/ticks/stats.rs b/crates/moon-core/src/db/tuner/ticks/stats.rs new file mode 100644 index 00000000..89045369 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/stats.rs @@ -0,0 +1,27 @@ +//! The KPI columns of the axis out of deals: the same `VarStats` shape every axis' matrix +//! draws, so the "Fact" of this axis and the "Fact" of the others are one number when the scope +//! is one scope. + +use super::Deal; +use crate::db::metrics::Tally; +use crate::db::tuner::{VarStats, stats_from_tally}; + +/// The KPI of `deals` as the report has them — the "Fact" column. +/// +/// Fed in the order given, which the reader keeps chronological (`read_deals`), because the +/// drawdown is a property of that order. +/// +/// Args: +/// deals: The rows, chronological by close. +pub fn fact_stats<'a>(deals: impl IntoIterator) -> VarStats { + let mut tally = Tally::default(); + let mut spent = 0.0; + for deal in deals { + tally.push(deal.fact_pnl); + spent += deal.spent; + } + stats_from_tally(tally, spent) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs new file mode 100644 index 00000000..106546a8 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -0,0 +1,36 @@ +use super::*; +use crate::db::tuner::ticks::Deltas; + +fn deal(pnl: f64, spent: f64) -> Deal { + Deal { + report_uid: 1, + core_uid: 7, + strategy_id: 42, + kind: "MoonShot".into(), + coin: "ACE".into(), + buy_ms: 1, + close_ms: 2, + buy_price: 1.0, + sell_price: 1.0, + spent, + is_short: false, + sell_reason: String::new(), + fact_pnl: pnl, + deltas: Deltas::default(), + tick: None, + } +} + +#[test] +fn fact_stats_tallies_the_rows_in_order() { + let deals = [deal(10.0, 100.0), deal(-4.0, 200.0), deal(6.0, 300.0)]; + let stats = fact_stats(&deals); + assert_eq!((stats.n, stats.wins), (3, 2)); + assert!((stats.profit - 12.0).abs() < 1e-9); + assert!((stats.avg - 4.0).abs() < 1e-9); + assert!((stats.avg_spent - 200.0).abs() < 1e-9); + assert!((stats.max_dd - 4.0).abs() < 1e-9, "{}", stats.max_dd); + let empty = fact_stats(&[]); + assert_eq!(empty.n, 0); + assert_eq!(empty.avg_spent, 0.0); +} diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index f5d73081..7a9db7ee 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -475,7 +475,7 @@ fn the_pre_spike_price_is_the_last_print_at_least_four_seconds_back() { } #[test] -fn a_take_the_tape_never_reaches_falls_back_to_the_fact() { +fn a_take_the_tape_never_reaches_leaves_the_position_open() { let fill = Fill { t_ms: 10_000, price: 99.0, @@ -487,9 +487,8 @@ fn a_take_the_tape_never_reaches_falls_back_to_the_fact() { (25_000, 99.5), ]); let out = ExitModel::new(&ExitParams::default()).exit(&deal(), &ticks, fill); - assert_eq!(out.kind, ExitKind::Fact); - assert_eq!(out.t_ms, 20_000); - assert!((out.price - 100.0).abs() < 1e-9); + assert_eq!(out.kind, ExitKind::OpenAtWindowEnd); + assert_eq!(out.t_ms, 25_000, "the tape's end"); } #[test] @@ -617,6 +616,7 @@ fn verify_marks_an_entry_inside_the_tolerance() { &EntryParams::MoonShot(mshot()), &ExitParams::default(), None, + None, ); assert_eq!(v.entry, Some(true)); assert_eq!(v.exit, Some(true)); @@ -624,7 +624,7 @@ fn verify_marks_an_entry_inside_the_tolerance() { } #[test] -fn verify_marks_a_missed_entry_and_leaves_the_fact_exit_unanswered() { +fn verify_marks_a_missed_entry_and_judges_the_exit_from_the_fact() { let ticks = tape(&[(0, 100.0), (10_000, 99.5), (20_000, 100.0)]); let v = verify( &deal(), @@ -632,10 +632,13 @@ fn verify_marks_a_missed_entry_and_leaves_the_fact_exit_unanswered() { &EntryParams::MoonShot(mshot()), &ExitParams::default(), None, + None, ); assert_eq!(v.entry, Some(false)); assert_eq!(v.fill, None); - assert_eq!(v.exit, None, "no fill, nothing to exit"); + // The exit is judged from the factual entry, so a missed entry does not silence it: the + // take off the fact's 99.0 is reached at t=20000. + assert_eq!(v.exit, Some(true)); // Fact entry, take never reached: the exit is the fact and answers nothing. let ticks = tape(&[ @@ -650,10 +653,12 @@ fn verify_marks_a_missed_entry_and_leaves_the_fact_exit_unanswered() { &EntryParams::Fact, &ExitParams::default(), None, + None, ); assert_eq!(v.entry, None); - assert_eq!(v.exit, None); - assert_eq!(v.exit_kind, Some(ExitKind::Fact)); + // The core did close it and the model never did: the exit group missed. + assert_eq!(v.exit, Some(false)); + assert_eq!(v.exit_kind, Some(ExitKind::OpenAtWindowEnd)); } #[test] @@ -667,6 +672,7 @@ fn verify_reports_the_deviation_of_an_entry_off_the_fact() { &EntryParams::MoonShot(mshot()), &ExitParams::default(), None, + None, ); assert_eq!(v.entry, Some(false)); let dev = v.entry_dev_pct.unwrap(); @@ -686,6 +692,7 @@ fn verify_leaves_a_take_unanswered_against_a_fact_another_rule_closed() { &EntryParams::MoonShot(mshot()), &ExitParams::default(), None, + None, ); assert_eq!(v.exit_kind, Some(ExitKind::Take)); assert_eq!(v.exit, None); @@ -788,7 +795,12 @@ fn the_descriptor_keys_every_field_the_builders_read_and_splits_the_groups() { let exit_any: Vec<_> = params_for(ParamGroup::Exit, "Spread") .map(|p| p.key) .collect(); - assert_eq!(exit_any, ["SellPrice", "SellDelay"]); + assert!(exit_any.starts_with(&["SellPrice", "SellDelay", "PriceDownTimer"])); + assert!( + !exit_any.contains(&"MShotSellAtLastPrice"), + "a MoonShot-only field" + ); + assert!(exit_any.contains(&"StopLoss") && exit_any.contains(&"SellShotDistance")); assert!(entry_model_for("MoonShot") && !entry_model_for("Spread")); } diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 52c08216..daba929f 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -17,6 +17,7 @@ use std::time::Duration; use rusqlite::{Connection, OpenFlags}; +use super::super::exit::ExitModel; use super::super::mshot::DEFAULT_LATENCY_MS; use super::super::params::{StrategyValues, exit_params, mshot_params, param_keys}; use super::super::*; @@ -31,15 +32,25 @@ use crate::symbol::{coin_match_key, coin_of_market}; /// The tape must reach this far back before the buy for the corridor to have a run-up. const RUN_UP_MS: i64 = 30_000; -/// The archived first point of the deal's own entry line, when the archive holds one. -fn archived_entry_start(deal: &Deal) -> Option<(i64, f64)> { - let entries = read_many(deal.core_uid, &[deal.report_uid]).ok()?; - match entries.get(&deal.report_uid)? { - TraceEntry::Lines(lines) => lines - .iter() - .find(|l| l.own && l.kind == ArchivedLineKind::Entry) - .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))), - TraceEntry::Empty { .. } => None, +/// The archived first point of the deal's own entry line and every point of its own exit +/// line, when the archive holds them. +fn archived_lines(deal: &Deal) -> (Option<(i64, f64)>, Option>) { + let Ok(entries) = read_many(deal.core_uid, &[deal.report_uid]) else { + return (None, None); + }; + match entries.get(&deal.report_uid) { + Some(TraceEntry::Lines(lines)) => { + let entry = lines + .iter() + .find(|l| l.own && l.kind == ArchivedLineKind::Entry) + .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))); + let exit = lines + .iter() + .find(|l| l.own && l.kind == ArchivedLineKind::Exit) + .map(|l| l.points.iter().map(|&(t, p)| (t as i64, p)).collect()); + (entry, exit) + } + _ => (None, None), } } @@ -144,19 +155,43 @@ fn real_data_reproduction() { } with_tape += 1; deal.tick = infer_tick(&ticks); - let entry_start = archived_entry_start(&deal); + let (entry_start, exit_points) = archived_lines(&deal); let sv = StrategyValues { values: &values, defaults: &defaults, }; let entry = EntryParams::MoonShot(mshot_params(&sv, DEFAULT_LATENCY_MS)); let exit = exit_params(&sv); - let plain = verify(&deal, &ticks, &entry, &exit, None); - let archived = verify(&deal, &ticks, &entry, &exit, entry_start); + // The modelled line beside the archive's moves, for the eye. + if let (Some(fill), Some(moves)) = ( + simulate(&deal, &ticks, &entry, &exit, entry_start).fill, + exit_points.as_deref().map(verify::archived_replacements), + ) { + let modelled = ExitModel::new(&exit).walk(&deal, &ticks, fill); + let fmt = |pts: &[(i64, f64)]| -> String { + pts.iter() + .take(6) + .map(|(t, p)| format!("{:+}ms {:.6}", t - deal.buy_ms, p)) + .collect::>() + .join(", ") + }; + let mine: Vec<(i64, f64)> = modelled.points.iter().map(|p| (p.t_ms, p.price)).collect(); + eprintln!(" model line: {}", fmt(&mine)); + eprintln!(" archive : {}", fmt(&moves)); + } + let plain = verify(&deal, &ticks, &entry, &exit, None, None); + let archived = verify( + &deal, + &ticks, + &entry, + &exit, + entry_start, + exit_points.as_deref(), + ); eprintln!( "{uid} {coin:<8} buy {buy:.6} | plain fill {fill:?} dev {dev:?} ✓{ok:?} | \ archived start {start:?} fill {fill2:?} dev {dev2:?} ✓{ok2:?} | \ - exit {exit_kind:?} ✓{exit_ok:?} dev {exit_dev:?} | {reason} | ticks {n} step {tick:?}", + exit {exit_kind:?} ✓{exit_ok:?} dev {exit_dev:?} line {line:?} | {reason} | ticks {n} step {tick:?}", uid = deal.report_uid, coin = deal.coin, buy = deal.buy_price, @@ -170,6 +205,7 @@ fn real_data_reproduction() { exit_kind = archived.exit_kind, exit_ok = archived.exit, exit_dev = round3(archived.exit_dev_pct), + line = archived.line_points, reason = deal.sell_reason, n = ticks.len(), tick = deal.tick, diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 01343a30..2d81bdf1 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -7,11 +7,29 @@ //! that cannot reproduce what happened optimizes noise. //! //! A group the model does not cover answers `None`, not `false`: a Spread's entry is not wrong, -//! it is not modelled, and phase 1's exit is not modelled wherever the take did not close it. +//! it is not modelled; an exit the core closed by a rule the model does not have is not a miss +//! of the rules it does. An exit the model never reached at all IS a miss. +//! +//! The exit is walked from the FACTUAL entry and held against two things: the price the core +//! sold at, and — when the order archive holds the trade's Exit line — every move the core +//! made with its sell, each of which the model must have made too within +//! [`POINT_TIME_TOLERANCE_MS`] and [`PRICE_TOLERANCE`]. A model that lands on the right price +//! by a different path has not reproduced the rule. +use super::exit::ExitModel; +use super::line::LinePoint; use super::{Deal, EntryParams, ExitKind, ExitParams, Fill, PRICE_TOLERANCE, simulate}; use crate::feed::types::Tick; +/// How far apart a modelled and an archived replacement may be in time and still be the same +/// move: the archive stamps the core's own moment, the model the print that triggered it. +pub const POINT_TIME_TOLERANCE_MS: i64 = 1_000; + +/// Tolerance on a STOP's price: the core's stop is a market order, and the report's +/// `sellprice` is what the book gave for it, while the model knows only the print that +/// fired it. Measured on the live tape (2026-09-20): 0.16–0.28 % between the two on a spike. +pub const STOP_PRICE_TOLERANCE: f64 = 0.003; + /// One trade's reproduction verdict, per group. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Verdict { @@ -28,8 +46,11 @@ pub struct Verdict { pub exit_dev_pct: Option, /// The modelled fill, for the tooltip. pub fill: Option, - /// How the modelled position closed, when it filled. + /// How the exit walk from the factual entry closed. pub exit_kind: Option, + /// Archived Exit points matched by the modelled line, as `(matched, archived)`; `None` + /// when the archive holds no Exit line for the trade. + pub line_points: Option<(usize, usize)>, } /// Relative deviation of `modelled` from `fact`, per cent of the fact. @@ -49,12 +70,15 @@ fn deviation_pct(modelled: f64, fact: f64) -> Option { /// model, which leaves `Verdict::entry` at `None`. /// exit: The sell-line parameters at the trade. /// entry_start: The archived first point of the entry line, when known. +/// exit_points: The archived Exit line's `(t_ms, price)` points, when the archive holds +/// them; the modelled line must re-place at each. pub fn verify( deal: &Deal, ticks: &[Tick], entry: &EntryParams, exit: &ExitParams, entry_start: Option<(i64, f64)>, + exit_points: Option<&[(i64, f64)]>, ) -> Verdict { let outcome = simulate(deal, ticks, entry, exit, entry_start); let entry_modelled = !matches!(entry, EntryParams::Fact); @@ -71,13 +95,35 @@ pub fn verify( // modelled take against a fact the core closed by its take. A take held against an // "Auto Price Down" fact is not a miss of the take model, it is a rule the model does not // have yet (phase 2), and it stays unanswered until it does. - let (exit_ok, exit_dev) = match outcome.exit { - Some(exit) if exit_rule_matches(exit.kind, &deal.sell_reason) => { - let dev = deviation_pct(exit.price, deal.sell_price); - let ok = dev.is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0); - (Some(ok), dev) - } - _ => (None, None), + // The exit group is judged from the FACTUAL entry, whatever the entry group modelled: the + // sell rules measure from the buy, and a fill the model placed a few ticks off would shift + // every level of a correctly reproduced line. The entry group has its own verdict above. + let fact_fill = Fill { + t_ms: deal.buy_ms, + price: deal.buy_price, + }; + let walked = ExitModel::new(exit).walk(deal, ticks, fact_fill); + let closed = walked.exit; + let (exit_ok, exit_dev, line_points) = if closed.kind == ExitKind::OpenAtWindowEnd { + // The core closed it; the model never did inside the same tape: a miss of the exit + // group, not an unanswered question. + (Some(false), None, None) + } else if exit_rule_matches(closed.kind, &deal.sell_reason) { + let dev = deviation_pct(closed.price, deal.sell_price); + let tolerance = if closed.kind == ExitKind::Stop { + STOP_PRICE_TOLERANCE + } else { + PRICE_TOLERANCE + }; + let price_ok = dev.is_some_and(|d| d.abs() <= tolerance * 100.0); + let points = exit_points.filter(|p| !p.is_empty()).map(|archived| { + let moves = archived_replacements(archived); + (matched_points(&walked.points, &moves), moves.len()) + }); + let line_ok = points.is_none_or(|(matched, total)| matched == total); + (Some(price_ok && line_ok), dev, points) + } else { + (None, None, None) }; Verdict { entry: entry_ok, @@ -85,20 +131,64 @@ pub fn verify( exit: exit_ok, exit_dev_pct: exit_dev, fill: outcome.fill, - exit_kind: outcome.exit.map(|e| e.kind), + exit_kind: Some(closed.kind), + line_points, + } +} + +/// The replacements an archived line records: its first point and every point whose price +/// differs from the one before it. The core files a line as a polyline — each move as the +/// old level's end and the new level's start at the same instant — so the moves are the +/// price changes, not the points. +/// +/// Args: +/// points: The archived `(t_ms, price)` points, in the archive's order. +pub fn archived_replacements(points: &[(i64, f64)]) -> Vec<(i64, f64)> { + let mut out: Vec<(i64, f64)> = Vec::new(); + for &(t, p) in points { + match out.last() { + Some(&(_, last)) if deviation_pct(p, last).is_some_and(|d| d.abs() <= 1e-9) => {} + _ => out.push((t, p)), + } } + out +} + +/// How many archived moves the modelled line re-placed at, within the tolerances. +fn matched_points(modelled: &[LinePoint], archived: &[(i64, f64)]) -> usize { + archived + .iter() + .filter(|&&(t, p)| { + modelled.iter().any(|m| { + (m.t_ms - t).abs() <= POINT_TIME_TOLERANCE_MS + && deviation_pct(m.price, p).is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0) + }) + }) + .count() } /// The core's `sellreason` for a position its take closed. pub const REASON_TAKE: &str = "Sell Price"; +/// The core's `sellreason` prefixes for a position its moving line closed. +pub const REASONS_LINE: [&str; 3] = ["Auto Price Down", "Sell Level", "SellShot"]; + +/// The core's `sellreason` prefix for a position its stop closed. +pub const REASON_STOP: &str = "StopLoss"; + /// Whether the model's exit rule is the one the core's `sellreason` names, so the two prices -/// are comparable. Only the take is modelled today; the moving line and the stop join in -/// phase 2 with their own reasons (`Auto Price Down`, `StopLoss …`). +/// are comparable: the take against "Sell Price", the moving line against the PriceDown / +/// SellLevel / SellShot reasons, the stop against "StopLoss …". fn exit_rule_matches(kind: ExitKind, sell_reason: &str) -> bool { + let reason = sell_reason.trim(); + let starts = |prefix: &str| { + reason.len() >= prefix.len() && reason[..prefix.len()].eq_ignore_ascii_case(prefix) + }; match kind { - ExitKind::Take => sell_reason.trim().eq_ignore_ascii_case(REASON_TAKE), - ExitKind::Line | ExitKind::Stop | ExitKind::Fact | ExitKind::OpenAtWindowEnd => false, + ExitKind::Take => reason.eq_ignore_ascii_case(REASON_TAKE), + ExitKind::Line => REASONS_LINE.iter().any(|r| starts(r)), + ExitKind::Stop => starts(REASON_STOP), + ExitKind::OpenAtWindowEnd => false, } } diff --git a/crates/moon-ui-gpui/src/analytics/bg.rs b/crates/moon-ui-gpui/src/analytics/bg.rs index 8050840c..15a6282d 100644 --- a/crates/moon-ui-gpui/src/analytics/bg.rs +++ b/crates/moon-ui-gpui/src/analytics/bg.rs @@ -29,6 +29,12 @@ pub(super) enum ReadLane { CoinKpi, CoinPicked, Time, + /// The Entry/Exit axis' database stage. + Ticks, + /// The Entry/Exit axis' replay stage. + TicksReplay, + /// The Entry/Exit axis' per-row tape fetch, one row at a time. + TicksFetch, } /// Active cancellation tokens keyed by the UI state each request may publish. @@ -132,6 +138,9 @@ impl AnalyticsView { ReadLane::CoinKpi, ReadLane::CoinPicked, ReadLane::Time, + ReadLane::Ticks, + ReadLane::TicksReplay, + ReadLane::TicksFetch, ]); } @@ -144,6 +153,9 @@ impl AnalyticsView { ReadLane::CoinKpi, ReadLane::CoinPicked, ReadLane::Time, + ReadLane::Ticks, + ReadLane::TicksReplay, + ReadLane::TicksFetch, ]); } diff --git a/crates/moon-ui-gpui/src/analytics/mod.rs b/crates/moon-ui-gpui/src/analytics/mod.rs index 1e4b0931..8c7f7f48 100644 --- a/crates/moon-ui-gpui/src/analytics/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/mod.rs @@ -627,6 +627,8 @@ pub struct AnalyticsView { /// The "By coin" mode: the table's view controls, the picked coins that define /// variant v1, and the two background results it renders from. coins: tuner::CoinsState, + /// "Entry/Exit" axis: the deals, their tape and the model's verdicts. + ticks: tuner::TicksState, /// The coin picker's read: the selected strategies' blacklist, with the core each coin /// belongs to and when it was added. coin_lists: tuner::CoinListsState, @@ -1072,6 +1074,7 @@ impl AnalyticsView { saved_tuner_compose, ), coins: tuner::CoinsState::load(saved_coin_sort), + ticks: tuner::TicksState::default(), coin_lists: tuner::CoinListsState::default(), time_tuner: tuner::TimeTunerState::load(), cal_from, @@ -1172,6 +1175,7 @@ impl AnalyticsView { self.tuner.invalidate_for_axis(); self.time_tuner.invalidate(); self.coins.invalidate(); + self.ticks.invalidate(); self.coin_lists.invalidate(); self.mark_report_data_stale(); self.request_report_refresh(RefreshUrgency::Writer, false, cx); @@ -1230,6 +1234,7 @@ impl AnalyticsView { self.tuner.mark_report_stale(); self.time_tuner.mark_report_stale(); self.coins.mark_report_stale(); + self.ticks.mark_report_stale(); } /// Acknowledge every committed generation visible when a refresh begins. @@ -1674,6 +1679,7 @@ impl AnalyticsView { // starting it now would plan against the PREVIOUS period's coins (or, on a first // show, against none at all). It is armed from the completion handler below. self.coins.invalidate(); + self.ticks.invalidate(); // The list panels ride the same reload, so they are retired with it — otherwise a // reply already in flight for the previous scope lands under the new heading. self.coin_lists.invalidate(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs index 1e7b6c1b..e18331cd 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs @@ -90,7 +90,10 @@ impl AnalyticsView { /// /// Returns: /// Lowercase field names mapped to their first available core-schema default. - fn filter_defaults(&self, cx: &Context) -> HashMap { + pub(in crate::analytics::tuner) fn filter_defaults( + &self, + cx: &Context, + ) -> HashMap { let backend = self.backend.read(cx); let store = backend.session.store(); let mut defaults = HashMap::new(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/list/table.rs b/crates/moon-ui-gpui/src/analytics/tuner/list/table.rs index dcd35780..d04c3681 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/list/table.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/list/table.rs @@ -210,8 +210,8 @@ impl AnalyticsView { .child(t!("analytics.strat.title").to_string()), ) // Order is deliberate and asserted by `theme_contract`: filter, then time, - // then coin. Each id stays bound to its own mode — the persisted per-axis - // column masks are keyed by mode, not by position. + // then coin, then entry/exit. Each id stays bound to its own mode — the + // persisted per-axis column masks are keyed by mode, not by position. .child(mode_btn( "sm-filters", StratMode::Filters, @@ -227,6 +227,11 @@ impl AnalyticsView { StratMode::Coins, t!("analytics.strat.mode_coin").to_string(), )) + .child(mode_btn( + "sm-ticks", + StratMode::Ticks, + t!("analytics.strat.mode_ticks").to_string(), + )) .child(div().flex_1()) .children({ // In multi-select show the count (amber) so the user knows a bulk save diff --git a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs index c7b06a21..afab2e16 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs @@ -41,6 +41,7 @@ mod coins; /// "By filter": the threshold grid, its histogram and its auto-suggestion. mod filter; /// "By time": the weekly schedule grid, the hour profile and the sliders. +mod ticks; mod time; // State types held by `AnalyticsView` (the parent). @@ -50,6 +51,7 @@ pub(super) use filter::state::TunerState; pub(super) use list::{ StratListFilter, VisibleRows, core_names_changed, published_groups, restore_strat_sort, }; +pub(super) use ticks::state::TicksState; pub(super) use time::state::TimeTunerState; // Column descriptors of the comparison tables — re-exported so the submodules (`list`) take @@ -109,14 +111,20 @@ pub(super) enum StratMode { Filters, Coins, Time, + /// "Entry/Exit" — the tape replay. + Ticks, } /// Every axis, for the code that has to touch all of them (seeding the per-axis column masks). /// /// A fourth axis added to `StratMode` without a slot in `cols_slot` fails to compile, and /// without an entry here fails this array's length — so neither can be forgotten silently. -pub(super) const STRAT_MODES: [StratMode; 3] = - [StratMode::Filters, StratMode::Coins, StratMode::Time]; +pub(super) const STRAT_MODES: [StratMode; 4] = [ + StratMode::Filters, + StratMode::Coins, + StratMode::Time, + StratMode::Ticks, +]; impl StratMode { /// This axis' slot in the persisted per-axis column masks. @@ -128,6 +136,9 @@ impl StratMode { StratMode::Filters => &mut m.filter, StratMode::Coins => &mut m.coins, StratMode::Time => &mut m.time, + // A slot never chosen takes the axis default the moment it is asked for, so the + // reference handed out is always a real mask. + StratMode::Ticks => m.ticks.get_or_insert(StratMode::Ticks.default_cols()), } } @@ -272,6 +283,7 @@ impl AnalyticsView { // The coin lists were edited against the PREVIOUS strategy; carried over they would // read as "this strategy's coins". `invalidate` retires them along with the numbers. self.coins.invalidate(); + self.ticks.invalidate(); self.coin_lists.invalidate(); self.reload_axis(self.strat_mode, cx); cx.notify(); @@ -373,6 +385,7 @@ impl AnalyticsView { self.time_tuner.reset_grid(); self.time_tuner.invalidate(); self.coins.invalidate(); + self.ticks.invalidate(); self.coin_lists.invalidate(); self.write_error = None; } @@ -481,6 +494,7 @@ impl AnalyticsView { // adding or removing a strategy retires both — including any unsaved tick, whose // baseline (the union of the selected strategies' saved lists) just changed. self.coins.invalidate(); + self.ticks.invalidate(); self.coin_lists.invalidate(); // The write banner speaks about an edit that was kept for a retry. `invalidate` has // just thrown that edit away with the scope it belonged to, so the banner would go on @@ -645,6 +659,8 @@ impl AnalyticsView { // "Coins" builds its table before the immutable reads of the right column // (the lazy search input needs &mut self). let coins_card = (mode == StratMode::Coins).then(|| self.coins_card(p, window, cx)); + // "Entry/Exit" likewise: its table settles the sort cache, which needs &mut self. + let ticks_card = (mode == StratMode::Ticks).then(|| self.ticks_card(p, cx)); // The right column's fold is one flag for every axis, and the rail carrying its caret is // built in BOTH states — collapsed, it is the only control left that can bring the column // back. The column below is then simply not built: `left` is already `.flex_1()`, so it @@ -663,6 +679,8 @@ impl AnalyticsView { // The coin table sits UNDER the list, where the histogram sits in "Filters": // both are "the detail behind the selected strategy". StratMode::Coins => left = left.children(coins_card), + // The deal table, in the same slot: the trades behind the selected strategy. + StratMode::Ticks => left = left.children(ticks_card), StratMode::Time => unreachable!("Time mode returns early above"), } @@ -706,7 +724,19 @@ impl AnalyticsView { .child(pick), ); } - StratMode::Filters | StratMode::Coins => {} + StratMode::Ticks if !side_collapsed => { + // The matrix on top, the parameter grid below — the same right column as the + // filter axis, with the grid read-only until phase 2. + main = main.child( + v_flex() + .w(design::font_w_px(cx, 470.0)) + .flex_none() + .h_full() + .min_h_0() + .child(self.ticks_side(p, cx)), + ); + } + StratMode::Filters | StratMode::Coins | StratMode::Ticks => {} StratMode::Time => unreachable!("Time mode returns early above"), } // The save confirmation window — an overlay on top of the tab. @@ -733,6 +763,7 @@ impl AnalyticsView { } StratMode::Time => self.reload_time(cx), StratMode::Coins => self.reload_coins(cx), + StratMode::Ticks => self.reload_ticks(cx), } } @@ -752,6 +783,7 @@ impl AnalyticsView { StratMode::Filters => self.reload_tuner_after_report(show_overlay, cx), StratMode::Time => self.reload_time_after_report(show_overlay, cx), StratMode::Coins => self.reload_coins_after_report(show_overlay, cx), + StratMode::Ticks => self.reload_ticks_after_report(show_overlay, cx), } } @@ -767,6 +799,7 @@ impl AnalyticsView { StratMode::Filters => self.tuner.needs_reload(), StratMode::Time => self.time_tuner.needs_reload(), StratMode::Coins => self.coins.needs_reload(), + StratMode::Ticks => self.ticks.needs_reload(), } } @@ -790,6 +823,7 @@ impl AnalyticsView { StratMode::Filters => self.reload_tuner(cx), StratMode::Time => self.reload_time(cx), StratMode::Coins => self.reload_coins(cx), + StratMode::Ticks => self.reload_ticks(cx), } } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/shared.rs b/crates/moon-ui-gpui/src/analytics/tuner/shared.rs index 600e2a57..b29ba940 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/shared.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/shared.rs @@ -27,6 +27,8 @@ pub(super) enum TunerKind { Time, /// "By coin" — the `CoinsBlackList` field the picker builds. Coins, + /// "Entry/Exit" — the tape replay; named by how it evaluates, not by what it shows. + Ticks, } /// A write target: a strategy ON A SPECIFIC core. `core` is the list row's `core_uid`, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/shell.rs b/crates/moon-ui-gpui/src/analytics/tuner/shell.rs index 6b83a803..d5fcf4a8 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/shell.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/shell.rs @@ -61,6 +61,9 @@ fn cfg_input_id(kind: TunerKind, which: CfgInput) -> &'static str { (TunerKind::Coins, CfgInput::Restarts) => "c-cfg-it", (TunerKind::Coins, CfgInput::MinTrades) => "c-cfg-mn", (TunerKind::Coins, CfgInput::Seed) => "c-cfg-seed", + (TunerKind::Ticks, CfgInput::Restarts) => "x-cfg-it", + (TunerKind::Ticks, CfgInput::MinTrades) => "x-cfg-mn", + (TunerKind::Ticks, CfgInput::Seed) => "x-cfg-seed", } } @@ -110,13 +113,14 @@ impl AnalyticsView { TunerKind::Filter => "f", TunerKind::Time => "t", TunerKind::Coins => "c", + TunerKind::Ticks => "x", }; // `None` — the axis has nothing to round. The coin list is text; a rounding control // beside it would be a switch that does nothing. let round = match kind { TunerKind::Filter => Some(self.tuner.round_results), TunerKind::Time => Some(self.time_tuner.round_results), - TunerKind::Coins => None, + TunerKind::Coins | TunerKind::Ticks => None, }; let mut header = h_flex() .w_full() @@ -163,7 +167,7 @@ impl AnalyticsView { this.time_tuner.invalidate_suggest(); } // No rounding on this axis — the control is hidden. - TunerKind::Coins => {} + TunerKind::Coins | TunerKind::Ticks => {} } cx.notify(); }); @@ -173,8 +177,10 @@ impl AnalyticsView { ); } // Render write controls for a retained anchor, but enable them only when the action - // authority admits at least one selected target. - if self.sel_strategy.is_some() { + // authority admits at least one selected target. The tape axis has nothing to write + // until its variants land (phase 2), so it shows no write controls at all rather than + // two buttons that do nothing. + if self.sel_strategy.is_some() && kind != TunerKind::Ticks { let workspace_target_visible = self.visible_target_count(self.action_core_ids()) > 0; // Copy is single-target only — hidden in multi-select (many addressees, no // per-target preview); bulk Save is the multi path. @@ -190,6 +196,7 @@ impl AnalyticsView { TunerKind::Filter => this.open_copy_dialog(window, cx), TunerKind::Time => this.time_open_copy_dialog(window, cx), TunerKind::Coins => this.coins_open_copy_dialog(window, cx), + TunerKind::Ticks => {} } cx.notify(); })) @@ -206,6 +213,7 @@ impl AnalyticsView { // The list differs from what the strategies hold — the same condition // the coin table's "changed" badge and its Revert button read. TunerKind::Coins => self.coins.has_changes(), + TunerKind::Ticks => false, }; MoonButton::new(SharedString::from(format!("tun-save-{k}"))) .variant(if dirty { @@ -220,6 +228,7 @@ impl AnalyticsView { TunerKind::Filter => this.open_save_dialog(cx), TunerKind::Time => this.time_open_save_dialog(cx), TunerKind::Coins => this.coins_open_save_dialog(cx), + TunerKind::Ticks => {} } cx.notify(); })) @@ -247,7 +256,7 @@ impl AnalyticsView { // The coin axis does not draw this row yet — its own controls arrive with the // selection metrics. Answering here keeps the match exhaustive rather than letting a // fourth axis compile into a silent default. - TunerKind::Coins => div().into_any_element(), + TunerKind::Coins | TunerKind::Ticks => div().into_any_element(), } } @@ -849,7 +858,7 @@ impl AnalyticsView { let cached = match kind { TunerKind::Filter => self.tuner.inputs.get(id), TunerKind::Time => self.time_tuner.inputs.get(id), - TunerKind::Coins => None, + TunerKind::Coins | TunerKind::Ticks => None, }; if let Some(state) = cached { return state.clone(); @@ -861,7 +870,7 @@ impl AnalyticsView { (TunerKind::Time, CfgInput::MinTrades) => self.time_tuner.min_trades.clone(), // The time row draws only the minimum-trades box, and the coin axis draws no row at // all, so neither has a value for the rest. - (TunerKind::Time, _) | (TunerKind::Coins, _) => String::new(), + (TunerKind::Time, _) | (TunerKind::Coins, _) | (TunerKind::Ticks, _) => String::new(), }; let ph = placeholder.to_string(); let state = cx.new(|cx| { @@ -915,7 +924,7 @@ impl AnalyticsView { this.time_tuner.min_trades = value; this.time_tuner.invalidate_suggest(); } - (TunerKind::Time, _) | (TunerKind::Coins, _) => {} + (TunerKind::Time, _) | (TunerKind::Coins, _) | (TunerKind::Ticks, _) => {} } if !matches!(ev, MoonInputEvent::Change) { cx.notify(); @@ -927,7 +936,7 @@ impl AnalyticsView { match kind { TunerKind::Filter => self.tuner.inputs.insert(id.to_string(), state.clone()), TunerKind::Time => self.time_tuner.inputs.insert(id.to_string(), state.clone()), - TunerKind::Coins => None, + TunerKind::Coins | TunerKind::Ticks => None, }; state } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/strat_columns.rs b/crates/moon-ui-gpui/src/analytics/tuner/strat_columns.rs index d9df8b18..70672cab 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/strat_columns.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/strat_columns.rs @@ -143,7 +143,13 @@ pub(in crate::analytics) fn restore_strat_columns( previous: Option, legacy_single: Option, ) -> moon_core::config::layout::StratColsByMode { - if let Some(current) = current { + if let Some(mut current) = current { + // The Entry/Exit slot postdates the key: a file saved before it has no value there, + // and the axis then takes its default. A saved zero is a deliberate all-hidden mask, + // kept like the other three slots keep theirs. + current + .ticks + .get_or_insert(super::StratMode::Ticks.default_cols()); return current; } let mut restored = previous.unwrap_or_else(|| { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/strat_columns/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/strat_columns/tests.rs index ec3f5bbd..e47e9d29 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/strat_columns/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/strat_columns/tests.rs @@ -105,6 +105,7 @@ fn legacy_masks_gain_new_metrics_without_reinterpreting_old_bits() { filter: COL_BIT_KIND | COL_BIT_LASTEDIT, coins: 0, time: COL_BIT_CORE, + ticks: None, }; let restored = restore_strat_columns(None, Some(previous), None); @@ -118,3 +119,34 @@ fn legacy_masks_gain_new_metrics_without_reinterpreting_old_bits() { let hidden = restore_strat_columns(Some(StratColsByMode::default()), Some(previous), None); assert_eq!(hidden.filter, 0, "current masks preserve a deliberate hide"); } + +/// A current per-mode key saved before the Entry/Exit axis existed has no value in its slot; +/// the axis takes its default. A saved mask - zero included - is kept, as the three older +/// slots keep theirs. +#[test] +fn a_saved_key_without_the_ticks_slot_takes_the_axis_default() { + let before_ticks = StratColsByMode { + filter: COL_BIT_KIND, + coins: 0, + time: 0, + ticks: None, + }; + let restored = restore_strat_columns(Some(before_ticks), None, None); + assert_eq!(restored.filter, COL_BIT_KIND); + assert_eq!(restored.coins, 0); + assert_eq!( + restored.ticks, + Some(super::super::StratMode::Ticks.default_cols()) + ); + for saved in [0, COL_BIT_CORE] { + let set = StratColsByMode { + ticks: Some(saved), + ..before_ticks + }; + assert_eq!( + restore_strat_columns(Some(set), None, None).ticks, + Some(saved), + "a saved choice is kept, an all-hidden one included" + ); + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs new file mode 100644 index 00000000..a0399879 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs @@ -0,0 +1,126 @@ +//! Columns of the DEAL table of the "Entry/Exit" axis: what each shows, its width, and the +//! sort it offers. Hand-laid like the coin table, because the two trailing cells (tape, model) +//! are marks, not metrics, and no shared descriptor draws a mark. + +/// One column of the deal table. +pub(in crate::analytics::tuner) struct DealCol { + /// Sort key and element-id suffix. + pub(in crate::analytics::tuner) key: &'static str, + /// Locale key of the heading. + pub(in crate::analytics::tuner) label: &'static str, + /// Preferred width, font-scaled px; the coin column is the flexible remainder. + pub(in crate::analytics::tuner) w: f32, + /// How narrow the column may be squeezed. + pub(in crate::analytics::tuner) min_w: f32, + /// Numbers right-align, marks and words centre or lead. + pub(in crate::analytics::tuner) align: Align, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(in crate::analytics::tuner) enum Align { + Left, + Right, + Center, +} + +pub(in crate::analytics::tuner) const COL_COIN: &str = "coin"; +pub(in crate::analytics::tuner) const COL_TIME: &str = "time"; +pub(in crate::analytics::tuner) const COL_BUY: &str = "buy"; +pub(in crate::analytics::tuner) const COL_SELL: &str = "sell"; +pub(in crate::analytics::tuner) const COL_RESULT: &str = "result"; +pub(in crate::analytics::tuner) const COL_DURATION: &str = "duration"; +pub(in crate::analytics::tuner) const COL_D5S: &str = "d5s"; +pub(in crate::analytics::tuner) const COL_D1M: &str = "d1m"; +pub(in crate::analytics::tuner) const COL_D1H: &str = "d1h"; +pub(in crate::analytics::tuner) const COL_DMARK: &str = "dmark"; +pub(in crate::analytics::tuner) const COL_PRICEBUG: &str = "pricebug"; +pub(in crate::analytics::tuner) const COL_REASON: &str = "reason"; +pub(in crate::analytics::tuner) const COL_TAPE: &str = "tape"; +pub(in crate::analytics::tuner) const COL_MODEL: &str = "model"; + +const fn col(key: &'static str, label: &'static str, w: f32, min_w: f32, align: Align) -> DealCol { + DealCol { + key, + label, + w, + min_w, + align, + } +} + +/// The columns after the coin, in reading order: when, the two prices and what came of them, +/// the market at the buy, why it closed, and the two marks of this axis. +pub(in crate::analytics::tuner) const DEAL_COLS: &[DealCol] = &[ + col( + COL_TIME, + "analytics.ticks.col.time", + 64.0, + 56.0, + Align::Right, + ), + col(COL_BUY, "analytics.ticks.col.buy", 72.0, 56.0, Align::Right), + col( + COL_SELL, + "analytics.ticks.col.sell", + 72.0, + 56.0, + Align::Right, + ), + col( + COL_RESULT, + "analytics.ticks.col.result", + 58.0, + 48.0, + Align::Right, + ), + col( + COL_DURATION, + "analytics.ticks.col.duration", + 52.0, + 44.0, + Align::Right, + ), + col(COL_D5S, "analytics.ticks.col.d5s", 46.0, 40.0, Align::Right), + col(COL_D1M, "analytics.ticks.col.d1m", 46.0, 40.0, Align::Right), + col(COL_D1H, "analytics.ticks.col.d1h", 46.0, 40.0, Align::Right), + col( + COL_DMARK, + "analytics.ticks.col.dmark", + 46.0, + 40.0, + Align::Right, + ), + col( + COL_PRICEBUG, + "analytics.ticks.col.pricebug", + 46.0, + 40.0, + Align::Right, + ), + col( + COL_REASON, + "analytics.ticks.col.reason", + 96.0, + 60.0, + Align::Left, + ), + col( + COL_TAPE, + "analytics.ticks.col.tape", + 40.0, + 36.0, + Align::Center, + ), + col( + COL_MODEL, + "analytics.ticks.col.model", + 44.0, + 40.0, + Align::Center, + ), +]; + +/// Width the coin column never drops below (font-scaled px). +pub(in crate::analytics::tuner) const DEAL_COIN_MIN_W: f32 = 64.0; +pub(in crate::analytics::tuner) const DEAL_ROW_PAD_X: f32 = 8.0; +pub(in crate::analytics::tuner) const DEAL_ROW_GAP: f32 = 6.0; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs new file mode 100644 index 00000000..fdceb67a --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -0,0 +1,198 @@ +//! "Fetch the tape" — the rows whose window the terminal does not hold, asked from the venue +//! one at a time through the same replay request a trade window makes. +//! +//! One at a time because the worker is one thread and the venues are rate-limited: a burst of +//! a hundred requests would queue a hundred tick walks behind every chart window's own. The +//! answers are read for their STATUS only (`NoRoute`, `OutOfRetention`, …) and shown in the +//! row's tooltip, so the button does not look broken on a venue without a public route; the +//! prints themselves land in the worker's tiles and on disk, and the row is then replayed +//! through the same held-data query the load uses. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc; + +use gpui::*; + +use super::super::super::AnalyticsView; +use super::load::replay_row; +use super::state::TapeStatus; +use crate::analytics::bg::ReadLane; +use moon_core::db::order_traces::{TraceEntry, read_many}; +use moon_core::db::tuner::ticks::Deal; +use moon_core::feed::report_traces::ArchivedLineKind; +use moon_core::market::trade_replay::worker::{self, TradeReplayRequest}; +use moon_core::market::trade_replay::{ + TickStatus, TradeReplayOutcome, margin_ms, replay_window_ms, +}; + +impl AnalyticsView { + /// Queue every fetchable row and start on the first. + pub(in crate::analytics::tuner) fn ticks_fetch_missing(&mut self, cx: &mut Context) { + if self.ticks.fetch.is_active() { + return; + } + let Some(data) = self.ticks.data.data() else { + return; + }; + let mut pending: Vec = data.fetchable().map(|r| r.deal.report_uid).collect(); + if pending.is_empty() { + return; + } + // Oldest first, so the ones nearest the venues' retention edge go before it moves. + pending.reverse(); + self.ticks.fetch.total = pending.len(); + self.ticks.fetch.done = 0; + self.ticks.fetch.pending = pending; + self.ticks_fetch_next(cx); + } + + /// Abandon the queue; the request in flight finishes on its own and is dropped on arrival. + pub(in crate::analytics::tuner) fn ticks_fetch_stop(&mut self, cx: &mut Context) { + let in_flight = self.ticks.fetch.in_flight; + self.ticks.fetch.clear(); + if let Some(uid) = in_flight { + self.ticks.update_row(uid, |row| { + if row.tape == TapeStatus::Fetching { + row.tape = TapeStatus::Missing; + } + }); + } + cx.notify(); + } + + /// Ask for the next queued row, if any. + fn ticks_fetch_next(&mut self, cx: &mut Context) { + let Some(uid) = self.ticks.fetch.pending.pop() else { + self.ticks.fetch.in_flight = None; + cx.notify(); + return; + }; + let Some((deal, address)) = self.ticks.data.data().and_then(|d| { + d.rows + .iter() + .find(|r| r.deal.report_uid == uid) + .and_then(|r| Some((r.deal.clone(), r.address.clone()?))) + }) else { + self.ticks_fetch_next(cx); + return; + }; + let Some(window) = replay_window_ms(deal.buy_ms, deal.close_ms, margin_ms()) else { + self.ticks_fetch_next(cx); + return; + }; + // The same addressing a trade window resolves: the live source for the exchange + // identity, the core's contract terms for how the prints are valued. + let resolved = { + let backend = self.backend.read(cx); + let source = backend.session.market_source(); + source + .replay_address(address.core_uid) + .ok() + .map(|replay_address| { + let terms = source.market_contract_terms(address.core_uid, &address.market); + let tick_value = moon_core::market::trade_replay::venue_caps::tick_value( + replay_address.venue, + terms.as_ref().map(|(quote, size)| (quote.as_str(), *size)), + ); + (replay_address, tick_value) + }) + }; + let Some((replay_address, tick_value)) = resolved else { + // The core went away since the load resolved the row: not a fetch failure, an + // address the row no longer has. + self.ticks + .update_row(uid, |row| row.tape = TapeStatus::NoAddress); + self.ticks_fetch_next(cx); + return; + }; + self.ticks.fetch.in_flight = Some(uid); + let seq = self.ticks.fetch.seq; + self.ticks + .update_row(uid, |row| row.tape = TapeStatus::Fetching); + let (reply, rx) = mpsc::channel(); + worker::request(TradeReplayRequest { + address: replay_address, + market: address.market.clone(), + window, + identity: fetch_identity(uid), + tick_value, + ticks: true, + cancel: Arc::new(AtomicBool::new(false)), + reply, + }); + let defaults = self.filter_defaults(cx); + self.spawn_latest_db( + &[ReadLane::TicksFetch], + false, + cx, + move || { + // The worker streams the candle stage, then the tick stage, then drops the + // sender: the last outcome before the drop is the tick stage's word. + let mut status = TickStatus::Failed; + while let Ok(outcome) = rx.recv() { + status = match outcome { + TradeReplayOutcome::Ready(series) => series.tick_status, + TradeReplayOutcome::Empty(_) | TradeReplayOutcome::Failed(_) => { + TickStatus::Failed + } + }; + } + let entry_start = archived_entry_start(&deal); + let mut row = super::state::DealRow { + deal, + tape: TapeStatus::Missing, + verdict: None, + address: Some(address), + }; + replay_row(&mut row, &defaults, entry_start); + if row.tape == TapeStatus::Missing { + row.tape = match status { + TickStatus::Served | TickStatus::Pending | TickStatus::Streaming => { + TapeStatus::Missing + } + refused => TapeStatus::Refused(refused), + }; + } + row + }, + move |this, row, cx| { + if this.ticks.fetch.seq != seq { + return; + } + let uid = row.deal.report_uid; + this.ticks.update_row(uid, |slot| { + slot.tape = row.tape; + slot.verdict = row.verdict; + slot.deal.tick = row.deal.tick; + }); + this.ticks.fetch.done += 1; + this.ticks_fetch_next(cx); + }, + ); + } +} + +/// A replay identity for a fetch, distinct from every chart window's: the row's own id, which +/// no window uses as a series discriminator. +fn fetch_identity(report_uid: i64) -> u64 { + // FNV-1a over the id, salted so a window's `identity` (an entity number) cannot collide. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325 ^ 0x7469_636b_7300_0000; + for byte in report_uid.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash | 1 +} + +/// The archived first point of one deal's own entry line. +fn archived_entry_start(deal: &Deal) -> Option<(i64, f64)> { + let entries = read_many(deal.core_uid, &[deal.report_uid]).ok()?; + match entries.get(&deal.report_uid)? { + TraceEntry::Lines(lines) => lines + .iter() + .find(|l| l.own && l.kind == ArchivedLineKind::Entry) + .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))), + TraceEntry::Empty { .. } => None, + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs new file mode 100644 index 00000000..0a741d6b --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -0,0 +1,229 @@ +//! The parameter grid of the "Entry/Exit" axis: the two groups of `TICK_PARAMS`, each behind +//! its own caret, with the "now" value of the selected strategies beside every field. +//! +//! Phase 1 is read-only: the variant columns and the "vary" switches arrive with the search. +//! The Entry group is shown only when every kind in the scope has an entry model; otherwise it +//! folds to one line saying whose entry is taken from the fact. + +use gpui::*; +use moon_ui::{MoonPalette, h_flex, v_flex}; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +use super::super::shared::{card, collapse_caret}; +use super::state::{NowValue, TicksData}; +use crate::design; +use crate::design::{moon, moon_alpha}; +use moon_core::db::tuner::ticks::params::{ParamGroup, TickParam, params_for}; + +impl AnalyticsView { + /// The grid card: the assumptions line under the title, then the two groups. + pub(in crate::analytics::tuner) fn ticks_grid( + &self, + p: MoonPalette, + cx: &Context, + ) -> AnyElement { + let data = self.ticks.data.data().cloned(); + let entry_open = self.ticks.entry_open; + let exit_open = self.ticks.exit_open; + let mut body = v_flex().w_full().flex_none(); + // What the model does not know, in one line — the spec's §4.4, kept where the user + // reads the numbers the assumptions shape. + body = body.child( + div() + .w_full() + .px(design::ui_px(cx, 12.0)) + .pb(design::ui_px(cx, 6.0)) + .font_family(design::ui_font()) + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_muted)) + .child(t!("analytics.ticks.assumptions").to_string()), + ); + let entry_modelled = data.as_ref().is_some_and(|d| d.entry_modelled()); + let unmodelled: Vec = data + .as_ref() + .map(|d| d.unmodelled_kinds().into_iter().map(String::from).collect()) + .unwrap_or_default(); + // Only a LOADED empty scope says so; a load in flight or a failed one has its own note. + let no_deals = data.as_ref().is_some_and(|d| d.kinds.is_empty()); + let entry_note = if no_deals { + Some(t!("analytics.ticks.no_deals").to_string()) + } else if entry_modelled { + None + } else { + Some( + t!( + "analytics.ticks.entry_from_fact", + kinds = unmodelled.join(", ") + ) + .to_string(), + ) + }; + body = body.child(self.ticks_group( + "an-ticks-grp-entry", + ParamGroup::Entry, + t!("analytics.ticks.group_entry").to_string(), + entry_note, + entry_open && entry_modelled, + entry_modelled, + data.as_deref(), + |this| this.ticks.entry_open = !this.ticks.entry_open, + p, + cx, + )); + body = body.child(self.ticks_group( + "an-ticks-grp-exit", + ParamGroup::Exit, + t!("analytics.ticks.group_exit").to_string(), + None, + exit_open, + true, + data.as_deref(), + |this| this.ticks.exit_open = !this.ticks.exit_open, + p, + cx, + )); + card( + t!("analytics.ticks.params_title").to_string(), + t!("analytics.ticks.params_sub").to_string(), + body.into_any_element(), + None, + p, + cx, + ) + } + + /// One group: a header line with its caret, then a row per field when unfolded. + #[allow(clippy::too_many_arguments)] + fn ticks_group( + &self, + id: &'static str, + group: ParamGroup, + title: String, + note: Option, + open: bool, + enabled: bool, + data: Option<&TicksData>, + toggle: impl Fn(&mut Self) + 'static, + p: MoonPalette, + cx: &Context, + ) -> AnyElement { + let caret = collapse_caret( + id, + !open, + t!("analytics.ticks.group_collapse").to_string(), + t!("analytics.ticks.group_expand").to_string(), + p, + cx.listener(move |this, _, _, cx| { + toggle(this); + cx.notify(); + }), + ); + let mut head = h_flex() + .w_full() + .px(design::ui_px(cx, 12.0)) + .py(design::ui_px(cx, 4.0)) + .items_center() + .gap(design::ui_px(cx, 6.0)) + .bg(moon(p.table_head)) + .font_family(design::ui_font()) + .child( + div() + .flex_none() + .text_size(design::t_body(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(if enabled { + moon(p.text) + } else { + moon(p.text_muted) + }) + .child(title), + ); + if let Some(note) = note { + head = head.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_muted)) + .child(note), + ); + } else { + head = head.child(div().flex_1()); + } + if enabled { + head = head.child(div().flex_none().child(caret)); + } + let mut out = v_flex().w_full().flex_none().child(head); + if !(open && enabled) { + return out.into_any_element(); + } + // The fields every kind in the scope understands: the union over the kinds present, + // in descriptor order. + let kinds: Vec<&str> = data + .map(|d| d.kinds.iter().map(String::as_str).collect()) + .unwrap_or_default(); + let fields: Vec<&'static TickParam> = moon_core::db::tuner::ticks::TICK_PARAMS + .iter() + .filter(|f| f.group == group) + .filter(|f| { + kinds + .iter() + .any(|k| params_for(group, k).any(|g| g.key == f.key)) + }) + .collect(); + out = out.child( + h_flex() + .w_full() + .px(design::ui_px(cx, 12.0)) + .py(design::ui_px(cx, 2.0)) + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_soft)) + .child( + div() + .flex_1() + .child(t!("analytics.tuner.field").to_string()), + ) + .child( + div() + .w(design::font_w_px(cx, 96.0)) + .flex_none() + .text_right() + .child(t!("analytics.ticks.now").to_string()), + ), + ); + for field in fields { + let now = data.and_then(|d| d.now.get(field.key)); + let (text, muted) = match now { + Some(NowValue::Same(v)) if !v.is_empty() => (v.clone(), false), + Some(NowValue::Same(_)) | None => ("—".to_string(), true), + Some(NowValue::Differs) => (t!("analytics.time.cur_varies").to_string(), true), + }; + out = out.child( + h_flex() + .w_full() + .px(design::ui_px(cx, 12.0)) + .py(design::ui_px(cx, 2.0)) + .items_center() + .border_t_1() + .border_color(moon_alpha(p.border, 0.5)) + .text_size(design::t_body(cx)) + .child(div().flex_1().min_w_0().truncate().child(field.key)) + .child( + div() + .w(design::font_w_px(cx, 96.0)) + .flex_none() + .text_right() + .text_color(if muted { + moon(p.text_muted) + } else { + moon(p.text) + }) + .child(text), + ), + ); + } + out.into_any_element() + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs new file mode 100644 index 00000000..ad81979b --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -0,0 +1,375 @@ +//! Background loads of the "Entry/Exit" axis, in two stages. +//! +//! Stage A reads the scope's deals, the whole-scope "Fact" KPI (the same SQL every axis' +//! "Fact" comes from) and the grid's "now" values off the database. Its completion resolves, +//! on the UI thread, where each deal's prints live — the core's exchange key and the coin's +//! market, which only the live market source knows — and starts stage B, which asks the +//! replay worker for the held tape of every deal, reads the archived entry line, and runs the +//! model on the parameters as of the buy. The axis' `LoadState` stays "loading" across both. +//! +//! This file only ever WRITES `TicksState`; the rendering only reads it. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::mpsc; +use std::time::Duration; + +use gpui::*; + +use super::super::super::AnalyticsView; +use super::state::{DealRow, NowValue, RowAddress, TapeStatus, TicksData}; +use crate::analytics::bg::ReadLane; +use crate::analytics::refresh::{CatchUpOutcome, report_result_is_stale}; +use moon_core::db::ReadFail; +use moon_core::db::order_traces::{TraceEntry, read_many}; +use moon_core::db::tuner::ticks::{ + Deal, DealsRead, EntryParams, entry_model_for, infer_tick, params, verify, +}; +use moon_core::db::tuner::{VarStats, Variant, strategy_current_values, strategy_values_at}; +use moon_core::feed::report_traces::ArchivedLineKind; +use moon_core::feed::types::Tick; +use moon_core::market::trade_replay::{ + Coverage, TickQuery, margin_ms, query_held, replay_window_ms, +}; + +/// How long stage B waits for the worker's answer on one deal. The worker serves a held +/// query right after candle jobs, so an answer past this means the worker is gone. +const HELD_ANSWER_WAIT: Duration = Duration::from_secs(10); + +/// The tape must reach this far back before the buy for the corridor to have a run-up. +const RUN_UP_MS: i64 = 30_000; + +/// What stage A brings back. +type StageA = ( + Result, + Result, ReadFail>, + HashMap, +); + +impl AnalyticsView { + /// Recompute the axis for the current scope. + pub(in crate::analytics) fn reload_ticks(&mut self, cx: &mut Context) { + self.reload_ticks_inner(false, true, cx); + } + + /// Recompute report-stale data through the writer-driven catch-up path. + pub(in crate::analytics) fn reload_ticks_after_report( + &mut self, + show_overlay: bool, + cx: &mut Context, + ) { + self.reload_ticks_inner(true, show_overlay, cx); + } + + fn reload_ticks_inner( + &mut self, + after_report: bool, + show_overlay: bool, + cx: &mut Context, + ) { + if !after_report { + self.report_busy_retries.reset(); + } + self.latest_reads + .cancel(&[ReadLane::Ticks, ReadLane::TicksReplay, ReadLane::TicksFetch]); + self.ticks.seq = self.ticks.seq.wrapping_add(1); + self.ticks.fetch.clear(); + let req = self.ticks.seq; + let report_req = self.current_report_generation(); + let q = self.tuner_query(); + let targets: Vec<(i64, Option)> = self.visible_target_keys(self.read_core_ids()); + let keys = params::param_keys(); + self.ticks.data.begin(); + self.ticks.kpi.begin(); + self.spawn_latest_db( + &[ReadLane::Ticks], + show_overlay, + cx, + move || { + let deals = moon_core::db::tuner::ticks::read_deals(&q); + let fact = moon_core::db::tuner::variant_stats(&q, &[Variant::default()]); + let now = now_values(&targets, &keys); + (deals, fact, now) + }, + move |this, (deals, fact, now): StageA, cx| { + if this.ticks.seq != req { + return; + } + let (read, fact) = match (deals, fact) { + (Ok(read), Ok(fact)) => (read, fact), + (Err(error), _) | (_, Err(error)) => { + let outcome = CatchUpOutcome::of_read::<()>(&Err(error.clone())); + this.ticks.dirty = report_result_is_stale( + report_req, + this.current_report_generation(), + true, + ); + let keep = this.keep_on_catch_up(after_report, outcome, report_req); + this.ticks.publish(Err(error), keep); + if after_report { + this.settle_report_refresh_retry(outcome.is_transient(), cx); + } + cx.notify(); + return; + } + }; + let addresses = this.resolve_addresses(&read.deals, cx); + this.start_replay_stage( + req, + report_req, + after_report, + read, + fact, + now, + addresses, + cx, + ); + }, + ); + } + + /// Where each deal's prints live, per distinct `(core, coin)`: the core's exchange key and + /// the catalog-verified market. A core that is not connected, or a coin its catalog does + /// not spell, resolves to nothing and the row says so. + fn resolve_addresses( + &self, + deals: &[Deal], + cx: &Context, + ) -> HashMap<(u64, String), Option>> { + let backend = self.backend.read(cx); + let source = backend.session.market_source(); + let mut out: HashMap<(u64, String), Option>> = HashMap::new(); + for deal in deals { + let key = (deal.core_uid, deal.coin.clone()); + if out.contains_key(&key) { + continue; + } + let quote = backend + .config + .servers + .iter() + .find(|s| s.id == deal.core_uid) + .map(|s| s.market.as_str()) + .unwrap_or_default(); + let address = source + .replay_address(deal.core_uid) + .ok() + .and_then(|address| { + let market = source.resolve_market(deal.core_uid, quote, &deal.coin)?; + let tick = source.price_step(deal.core_uid, &market); + Some(Arc::new(RowAddress { + core_uid: deal.core_uid, + exchange_key: address.exchange_key, + market, + tick, + })) + }); + out.insert(key, address); + } + out + } + + /// Stage B: the held tape of every deal, the archived entry line, the model on the + /// parameters as of the buy. Off the UI thread; the worker's answers are waited for one + /// at a time. + #[allow(clippy::too_many_arguments)] + fn start_replay_stage( + &mut self, + req: u64, + report_req: u64, + after_report: bool, + read: DealsRead, + fact: Vec, + now: HashMap, + addresses: HashMap<(u64, String), Option>>, + cx: &mut Context, + ) { + let defaults = self.filter_defaults(cx); + self.spawn_latest_db( + &[ReadLane::TicksReplay], + false, + cx, + move || { + let mut rows: Vec = read + .deals + .into_iter() + .map(|deal| { + let address = addresses + .get(&(deal.core_uid, deal.coin.clone())) + .cloned() + .flatten(); + DealRow { + deal, + tape: if address.is_some() { + TapeStatus::Missing + } else { + TapeStatus::NoAddress + }, + verdict: None, + address, + } + }) + .collect(); + let traces = archived_entry_starts(&rows); + for row in &mut rows { + // Each row waits on the worker; a scope change cancels this lane, and the + // wait is not a statement the progress handler could interrupt. + if moon_core::db::current_is_cancelled() { + break; + } + replay_row(row, &defaults, traces.get(&row.deal.report_uid).copied()); + } + let mut kinds: Vec = rows.iter().map(|r| r.deal.kind.clone()).collect(); + kinds.sort(); + kinds.dedup(); + let mut data = TicksData { + rows, + without_ms: read.without_ms, + kpi: fact, + entry_share: (0, 0), + exit_share: (0, 0), + kinds, + now, + }; + data.refresh_summary(); + data + }, + move |this, data, cx| { + if this.ticks.seq != req { + return; + } + this.ticks.dirty = + report_result_is_stale(report_req, this.current_report_generation(), false); + this.ticks.publish(Ok(data), false); + if after_report { + this.settle_report_refresh_retry(false, cx); + } + cx.notify(); + }, + ); + } +} + +/// The grid's "now" column: every selected strategy's current value per field, folded to +/// one value or "varies". +fn now_values(targets: &[(i64, Option)], keys: &[String]) -> HashMap { + let mut seen: HashMap>> = HashMap::new(); + for &(sid, core) in targets { + let values = strategy_current_values(sid, core, keys); + for key in keys { + seen.entry(key.clone()) + .or_default() + .push(values.get(key).cloned()); + } + } + seen.into_iter() + .map(|(key, values)| { + let first = values.first().cloned().flatten(); + let same = values.iter().all(|v| v.as_deref() == first.as_deref()); + let value = if same { + NowValue::Same(first.unwrap_or_default()) + } else { + NowValue::Differs + }; + (key, value) + }) + .collect() +} + +/// The archived first point of each deal's own entry line, read once per core. +fn archived_entry_starts(rows: &[DealRow]) -> HashMap { + let mut by_core: HashMap> = HashMap::new(); + for row in rows { + by_core + .entry(row.deal.core_uid) + .or_default() + .push(row.deal.report_uid); + } + let mut out = HashMap::new(); + for (core, uids) in by_core { + let Ok(entries) = read_many(core, &uids) else { + continue; + }; + for (uid, entry) in entries { + if let TraceEntry::Lines(lines) = entry + && let Some(start) = lines + .iter() + .find(|l| l.own && l.kind == ArchivedLineKind::Entry) + .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))) + { + out.insert(uid, start); + } + } + } + out +} + +/// The held prints of one deal's window, through the worker. `None` when the worker did not +/// answer in time. +pub(super) fn held_tape( + address: &RowAddress, + deal: &Deal, +) -> Option<(Vec, Coverage, Coverage)> { + let window = replay_window_ms(deal.buy_ms, deal.close_ms, margin_ms())?; + let spans = window.focus_spans(); + let (reply, rx) = mpsc::channel(); + query_held(TickQuery { + exchange_key: address.exchange_key.clone(), + market: address.market.clone(), + spans: spans.clone(), + reply, + }); + let answer = rx.recv_timeout(HELD_ANSWER_WAIT).ok()?; + Some((answer.ticks, answer.covered, spans)) +} + +/// Run the model on one row, from what the worker holds; a row without an address is left +/// as it is. +pub(super) fn replay_row( + row: &mut DealRow, + defaults: &HashMap, + entry_start: Option<(i64, f64)>, +) { + let Some(address) = row.address.clone() else { + return; + }; + let Some((ticks, covered, spans)) = held_tape(&address, &row.deal) else { + row.tape = TapeStatus::Missing; + row.verdict = None; + return; + }; + let first = ticks.first().map(|t| t.time_ms as i64); + let last = ticks.last().map(|t| t.time_ms as i64); + let complete = covered.covers(&spans) + && first.is_some_and(|f| f <= row.deal.buy_ms - RUN_UP_MS) + && last.is_some_and(|l| l >= row.deal.close_ms); + if !complete { + row.tape = TapeStatus::Missing; + row.verdict = None; + return; + } + row.tape = TapeStatus::Covered; + row.deal.tick = address.tick.or_else(|| infer_tick(&ticks)); + let keys = params::param_keys(); + let values = strategy_values_at( + row.deal.strategy_id, + Some(row.deal.core_uid), + row.deal.buy_ms, + &keys, + ) + .unwrap_or_default(); + let sv = params::StrategyValues { + values: &values, + defaults, + }; + let entry = if entry_model_for(&row.deal.kind) { + EntryParams::MoonShot(params::mshot_params( + &sv, + moon_core::db::tuner::ticks::mshot::DEFAULT_LATENCY_MS, + )) + } else { + EntryParams::Fact + }; + let exit = params::exit_params(&sv); + row.verdict = Some(verify(&row.deal, &ticks, &entry, &exit, entry_start, None)); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs new file mode 100644 index 00000000..31f3cc75 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -0,0 +1,493 @@ +//! The "Entry/Exit" axis of the strategy tuner: the scope's deals replayed over the trade +//! tape rather than masked by SQL. +//! +//! Left, 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. Right: the shared "Fact vs …" matrix (the whole +//! scope beside the replayable subset, captioned with the ✓ shares) and the parameter grid. +//! Phase 1 stops there — the variant columns and the search land with phase 2 — and says so +//! in its captions rather than hiding the gap. +//! +//! The model itself is `moon_core::db::tuner::ticks`; this module only feeds it and draws +//! what it says. + +use gpui::prelude::FluentBuilder; +use gpui::*; +use moon_ui::{ + MoonButton, MoonButtonVariant, MoonPalette, MoonScrollbarVisibility, MoonTooltipView, + MoonVirtualList, h_flex, v_flex, +}; +use rust_i18n::t; + +use super::super::AnalyticsView; +use super::kpi::{VarLabel, kpi_matrix_card}; +use super::{sort_arrow_of, toggle_sort_key}; +use crate::design; +use crate::design::{moon, moon_alpha}; +use columns::*; +use state::{DealRow, TapeStatus}; + +pub(in crate::analytics::tuner) mod columns; +mod fetch; +mod grid; +mod load; +pub(in crate::analytics::tuner) mod rows; +pub(in crate::analytics) mod state; + +impl AnalyticsView { + /// The deal table card — sits UNDER the strategy list, where the coin table sits in "By + /// coin". + pub(in crate::analytics::tuner) fn ticks_card( + &mut self, + p: MoonPalette, + cx: &mut Context, + ) -> AnyElement { + let scale = design::font_scale(cx); + let scope = self.scope_label(); + let zone = self.query().axis.zone(); + // The order is settled before the data is viewed: both live in `ticks`, and the sort + // cache needs the mutable half. + let drawn = rows::order_for(&mut self.ticks).len(); + let summary = self.ticks.data.view(|d| d.rows.is_empty()).map(|d| { + ( + d.rows.len(), + d.covered(), + d.fetchable().count(), + d.without_ms, + ) + }); + let (body, total, covered, fetchable, without_ms) = match summary { + Err(note) => ( + super::super::note_el("an-ticks-note", note, 10.0, p, cx), + 0usize, + 0usize, + 0usize, + 0usize, + ), + Ok((total, covered, fetchable, without_ms)) => { + let weak = cx.entity().downgrade(); + let row_h = deal_row_h(cx); + let list = + MoonVirtualList::new("an-ticks-rows", drawn, row_h, move |ix, _w, app| { + weak.upgrade() + .and_then(|e| { + let view = e.read(app); + // Order and rows are read from the view in ONE go, so an + // index is never applied to a row set it was not built + // against. + let order = view.ticks.order.as_ref()?; + let row = + view.ticks.data.data()?.rows.get(*order.order.get(ix)?)?; + Some(deal_row(row, p, scale, row_h, zone, app)) + }) + .unwrap_or_else(|| div().into_any_element()) + }) + .surface(false) + .border(false) + .radius(0.0) + .scrollbar_visibility(MoonScrollbarVisibility::Hover) + .into_any_element(); + (list, total, covered, fetchable, without_ms) + } + }; + let fetch_active = self.ticks.fetch.is_active(); + let fetch_label = if fetch_active { + t!( + "analytics.ticks.fetch_progress", + done = self.ticks.fetch.done, + total = self.ticks.fetch.total + ) + .to_string() + } else { + t!("analytics.ticks.fetch_btn").to_string() + }; + v_flex() + .w_full() + .flex_1() + .min_h_0() + .rounded(design::ui_px(cx, 8.0)) + .bg(moon(p.panel)) + .border_1() + .border_color(moon(p.border)) + .overflow_hidden() + .child( + h_flex() + .w_full() + .flex_none() + .h(design::fit_h_px(cx, 34.0, 14.0, 8.0)) + .px(design::ui_px(cx, 12.0)) + .items_center() + .gap(design::ui_px(cx, 8.0)) + .child( + div() + .flex_none() + .font_family(design::ui_font()) + .text_size(design::t_title(cx)) + .font_weight(FontWeight::SEMIBOLD) + .child(t!("analytics.ticks.title").to_string()), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_muted)) + .child(scope), + ) + // "N with tape of M · K without stamps": the honest size of the sample. + .child( + div() + .flex_none() + .font_family(design::ui_font()) + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_muted)) + .child( + t!( + "analytics.ticks.coverage", + covered = covered, + total = total, + without = without_ms + ) + .to_string(), + ), + ) + .when(fetchable > 0 || fetch_active, |el| { + el.child( + div().font_family(design::ui_font()).child( + MoonButton::new("an-ticks-fetch") + .variant(if fetch_active { + MoonButtonVariant::Amber + } else { + MoonButtonVariant::Soft + }) + .label(fetch_label) + .on_click(cx.listener(move |this, _, _, cx| { + if fetch_active { + this.ticks_fetch_stop(cx); + } else { + this.ticks_fetch_missing(cx); + } + cx.notify(); + })) + .render(), + ), + ) + }), + ) + .child(self.deal_header(p, cx)) + // The virtual list owns its own scrolling. + .child(div().w_full().flex_1().min_h_0().child(body)) + .into_any_element() + } + + /// The table's heading row: every column sortable, the arrow on the active one. + fn deal_header(&self, p: MoonPalette, cx: &Context) -> impl IntoElement + use<> { + let scale = design::font_scale(cx); + let sortable = + |id: SharedString, title: String, key: &'static str, col: Option<&DealCol>| { + let arrow = sort_arrow_of(&self.ticks.sort, key); + let d = div() + .id(id) + .flex_none() + .truncate() + .cursor_pointer() + .text_color(if arrow.is_empty() { + moon(p.text_soft) + } else { + moon(p.amber) + }) + .child(format!("{title}{arrow}")) + .on_click(cx.listener(move |this, _, _, cx| { + toggle_sort_key(&mut this.ticks.sort, key); + this.ticks.order = None; + cx.notify(); + })); + match col { + Some(col) => { + let d = d + .w(px(col.w * scale)) + .min_w(px(col.min_w * scale)) + .flex_shrink_1(); + match col.align { + Align::Right => d.text_right(), + Align::Center => d.text_center(), + Align::Left => d, + } + } + None => d.flex_1().min_w(px(DEAL_COIN_MIN_W * scale)), + } + }; + h_flex() + .w_full() + .flex_none() + .h(design::fit_h_px(cx, 22.0, 12.0, 5.0)) + .px(design::ui_px(cx, DEAL_ROW_PAD_X)) + .gap(design::ui_px(cx, DEAL_ROW_GAP)) + .items_center() + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_soft)) + .bg(moon(p.table_head)) + .child(sortable( + "an-ticks-hdr-coin".into(), + t!("analytics.col.coin").to_string(), + COL_COIN, + None, + )) + .children(DEAL_COLS.iter().map(|c| { + sortable( + SharedString::from(format!("an-ticks-hdr-{}", c.key)), + t!(c.label).to_string(), + c.key, + Some(c), + ) + })) + } + + /// The right column of the axis: the matrix on top, the grid below it, scrolling as one. + pub(in crate::analytics::tuner) fn ticks_side( + &self, + p: MoonPalette, + cx: &Context, + ) -> AnyElement { + v_flex() + .w_full() + .h_full() + .min_h_0() + .gap(design::ui_px(cx, 8.0)) + .child(self.ticks_kpi(p, cx)) + .child( + div() + .id("an-ticks-grid-scroll") + .w_full() + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child(self.ticks_grid(p, cx)), + ) + .into_any_element() + } + + /// "Fact vs …": the whole scope beside the rows the tape covers, the second captioned + /// with the ✓ shares of both groups — the model's own account of itself. + fn ticks_kpi(&self, p: MoonPalette, cx: &Context) -> AnyElement { + let (covered, total, entry, exit) = self + .ticks + .data + .data() + .map(|d| (d.covered(), d.rows.len(), d.entry_share, d.exit_share)) + .unwrap_or_default(); + let share = |(hits, n): (usize, usize)| -> String { + if n == 0 { + "—".to_string() + } else { + format!("{:.0} %", hits as f64 / n as f64 * 100.0) + } + }; + let labels = vec![VarLabel::with_sub( + t!("analytics.ticks.subset").to_string(), + t!( + "analytics.ticks.subset_sub", + n = covered, + m = total, + entry = share(entry), + exit = share(exit) + ) + .to_string(), + )]; + // `TicksState::kpi` is `TicksData::kpi` — `[fact, subset]` — under the matrix's shape. + kpi_matrix_card( + &self.ticks.kpi, + self.scope_label(), + &labels, + self.kpi_collapsed, + p, + cx, + ) + } +} + +/// Height of one deal row, in base px — the single pitch the list and the row share. +fn deal_row_h(cx: &App) -> f32 { + design::fit_h_value(cx, 24.0, 14.0, 5.0) +} + +/// Wall-clock time of a millisecond stamp in the selected zone. +fn hms(unix_ms: i64, zone: chrono_tz::Tz) -> String { + moon_core::util::display_time::at_millis(unix_ms, zone) + .map(|value| value.format("%H:%M:%S").to_string()) + .unwrap_or_default() +} + +/// A duration in the shortest unit that keeps it readable. +fn duration_text(ms: i64) -> String { + let s = ms.max(0) as f64 / 1000.0; + if s < 60.0 { + format!("{s:.0}s") + } else if s < 3600.0 { + format!("{:.1}m", s / 60.0) + } else { + format!("{:.1}h", s / 3600.0) + } +} + +/// A delta cell: signed, one decimal, dimmed at zero. +fn delta_text(v: f64) -> String { + if v == 0.0 { + "—".to_string() + } else { + format!("{v:+.1}") + } +} + +/// The mark of the tape column and its tooltip. +fn tape_mark(tape: TapeStatus) -> (&'static str, String) { + match tape { + TapeStatus::Covered => ("●", t!("analytics.ticks.tape_covered").to_string()), + TapeStatus::Missing => ("○", t!("analytics.ticks.tape_missing").to_string()), + TapeStatus::Fetching => ("…", t!("analytics.ticks.tape_fetching").to_string()), + TapeStatus::NoAddress => ("·", t!("analytics.ticks.tape_no_address").to_string()), + TapeStatus::Refused(status) => ( + "✕", + t!( + "analytics.ticks.tape_refused", + status = format!("{status:?}") + ) + .to_string(), + ), + } +} + +/// The mark of the model column — one glyph per group — and its tooltip with the deviations. +fn model_mark(row: &DealRow) -> (String, String) { + let Some(v) = row.verdict else { + return (String::new(), String::new()); + }; + let glyph = |x: Option| match x { + Some(true) => "✓", + Some(false) => "✗", + None => "·", + }; + let dev = |d: Option| match d { + Some(d) => format!("{d:+.3} %"), + None => "—".to_string(), + }; + ( + format!("{}{}", glyph(v.entry), glyph(v.exit)), + t!( + "analytics.ticks.model_tip", + entry = dev(v.entry_dev_pct), + exit = dev(v.exit_dev_pct) + ) + .to_string(), + ) +} + +/// One deal row. +fn deal_row( + row: &DealRow, + p: MoonPalette, + scale: f32, + row_h: f32, + zone: chrono_tz::Tz, + cx: &App, +) -> AnyElement { + let d = &row.deal; + let result = rows::result_pct(row); + let cell = |col: &DealCol, text: String, color: u32, tip: Option| { + let mut el = div() + .w(px(col.w * scale)) + .min_w(px(col.min_w * scale)) + .flex_shrink_1() + .flex_none() + .truncate() + .text_color(moon(color)) + .child(text); + el = match col.align { + Align::Right => el.text_right(), + Align::Center => el.text_center(), + Align::Left => el, + }; + match tip { + Some(tip) if !tip.is_empty() => el + .id(SharedString::from(format!( + "an-ticks-{}-{}", + col.key, d.report_uid + ))) + .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(tip.clone())).into()) + .into_any_element(), + _ => el.into_any_element(), + } + }; + let (tape_glyph, tape_tip) = tape_mark(row.tape); + let (model_glyph, model_tip) = model_mark(row); + let text = p.text; + let mut el = h_flex() + .id(SharedString::from(format!("an-tickrow-{}", d.report_uid))) + .w_full() + .h(px(row_h)) + .px(design::ui_px(cx, DEAL_ROW_PAD_X)) + .gap(design::ui_px(cx, DEAL_ROW_GAP)) + .items_center() + .bg(moon(p.table_body)) + .border_t_1() + .border_color(moon_alpha(p.border, 0.5)) + .child( + div() + .flex_1() + .min_w(design::font_w_px(cx, DEAL_COIN_MIN_W)) + .truncate() + .child(d.coin.clone()), + ); + for col in DEAL_COLS { + let (value, color, tip) = match col.key { + COL_TIME => (hms(d.buy_ms, zone), text, None), + COL_BUY => (moon_core::util::fmt::adaptive(d.buy_price), text, None), + COL_SELL => (moon_core::util::fmt::adaptive(d.sell_price), text, None), + COL_RESULT => ( + format!("{result:+.2}"), + if result > 0.0 { + p.green + } else if result < 0.0 { + p.red + } else { + p.text_muted + }, + None, + ), + COL_DURATION => (duration_text(d.close_ms - d.buy_ms), p.text_muted, None), + COL_D5S => (delta_text(d.deltas.d5s), p.text_muted, None), + COL_D1M => (delta_text(d.deltas.d1m), p.text_muted, None), + COL_D1H => (delta_text(d.deltas.d1h), p.text_muted, None), + COL_DMARK => (delta_text(d.deltas.dmark), p.text_muted, None), + COL_PRICEBUG => (delta_text(d.deltas.pricebug), p.text_muted, None), + COL_REASON => ( + d.sell_reason.clone(), + p.text_muted, + Some(d.sell_reason.clone()), + ), + COL_TAPE => ( + tape_glyph.to_string(), + match row.tape { + TapeStatus::Covered => p.green, + TapeStatus::Fetching => p.amber, + _ => p.text_muted, + }, + Some(tape_tip.clone()), + ), + COL_MODEL => ( + model_glyph.clone(), + match row.verdict.and_then(|v| v.entry.or(v.exit)) { + Some(true) => p.green, + Some(false) => p.red, + None => p.text_muted, + }, + Some(model_tip.clone()), + ), + _ => (String::new(), text, None), + }; + el = el.child(cell(col, value, color, tip)); + } + el = el.hover(move |s| s.bg(moon_alpha(p.panel_high, 0.9))); + el.into_any_element() +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs new file mode 100644 index 00000000..8dc36a43 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs @@ -0,0 +1,113 @@ +//! The deal table's row order — a permutation over the loaded rows, cached against the data +//! generation and the sort, so a repaint that changed neither reuses it instead of sorting +//! hundreds of deals per frame. + +use super::columns::*; +use super::state::{DealRow, TapeStatus, TicksState}; + +/// The sorted order and what it was built for. +pub(in crate::analytics::tuner) struct OrderCache { + pub(in crate::analytics::tuner) rows_rev: u64, + pub(in crate::analytics::tuner) sort: Option<(String, bool)>, + /// Indices into `TicksData::rows`. + pub(in crate::analytics::tuner) order: Vec, +} + +/// The current order, rebuilt only when the rows or the sort changed. +pub(in crate::analytics::tuner) fn order_for(state: &mut TicksState) -> &[usize] { + let fresh = state + .order + .as_ref() + .is_some_and(|c| c.rows_rev == state.rows_rev && c.sort == state.sort); + if !fresh { + let rows: &[DealRow] = state.data.data().map(|d| d.rows.as_slice()).unwrap_or(&[]); + let mut order: Vec = (0..rows.len()).collect(); + if let Some((key, desc)) = &state.sort { + sort_indices(rows, &mut order, key, *desc); + } + state.order = Some(OrderCache { + rows_rev: state.rows_rev, + sort: state.sort.clone(), + order, + }); + } + state + .order + .as_ref() + .map(|c| c.order.as_slice()) + .unwrap_or(&[]) +} + +/// Result of a deal in per cent of what was spent, as the report has it. +pub(in crate::analytics::tuner) fn result_pct(row: &DealRow) -> f64 { + let d = &row.deal; + if d.buy_price <= 0.0 { + return 0.0; + } + let raw = (d.sell_price - d.buy_price) / d.buy_price * 100.0; + if d.is_short { -raw } else { raw } +} + +/// Rank of a tape status for sorting: covered first, then fetchable, then the rest. +fn tape_rank(tape: TapeStatus) -> u8 { + match tape { + TapeStatus::Covered => 0, + TapeStatus::Fetching => 1, + TapeStatus::Missing => 2, + TapeStatus::Refused(_) => 3, + TapeStatus::NoAddress => 4, + } +} + +/// Rank of a verdict for sorting: both hits first, unanswered last. +fn model_rank(row: &DealRow) -> u8 { + match row.verdict { + None => 6, + Some(v) => { + let score = |x: Option| match x { + Some(true) => 0, + Some(false) => 2, + None => 1, + }; + score(v.entry) + score(v.exit) + } + } +} + +fn sort_indices(rows: &[DealRow], order: &mut [usize], key: &str, desc: bool) { + let by_f64 = |f: &dyn Fn(&DealRow) -> f64, order: &mut [usize]| { + order.sort_by(|&a, &b| { + let (x, y) = (f(&rows[a]), f(&rows[b])); + let c = x.total_cmp(&y); + if desc { c.reverse() } else { c } + }); + }; + match key { + COL_COIN => order.sort_by(|&a, &b| { + let c = rows[a].deal.coin.cmp(&rows[b].deal.coin); + if desc { c.reverse() } else { c } + }), + COL_BUY => by_f64(&|r| r.deal.buy_price, order), + COL_SELL => by_f64(&|r| r.deal.sell_price, order), + COL_RESULT => by_f64(&result_pct, order), + COL_DURATION => by_f64(&|r| (r.deal.close_ms - r.deal.buy_ms) as f64, order), + COL_D5S => by_f64(&|r| r.deal.deltas.d5s, order), + COL_D1M => by_f64(&|r| r.deal.deltas.d1m, order), + COL_D1H => by_f64(&|r| r.deal.deltas.d1h, order), + COL_DMARK => by_f64(&|r| r.deal.deltas.dmark, order), + COL_PRICEBUG => by_f64(&|r| r.deal.deltas.pricebug, order), + COL_REASON => order.sort_by(|&a, &b| { + let c = rows[a].deal.sell_reason.cmp(&rows[b].deal.sell_reason); + if desc { c.reverse() } else { c } + }), + COL_TAPE => by_f64(&|r| f64::from(tape_rank(r.tape)), order), + COL_MODEL => by_f64(&|r| f64::from(model_rank(r)), order), + // `COL_TIME` and anything unknown: by the entry time. + _ => by_f64(&|r| r.deal.buy_ms as f64, order), + } +} + +// Explicit imports, never `use super::*`: the parent re-exports `gpui::*`, whose own `test` +// shadows the built-in attribute and makes `#[test]` expand recursively. +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs new file mode 100644 index 00000000..e0b2f032 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -0,0 +1,122 @@ +use super::super::columns::{COL_MODEL, COL_RESULT, COL_TAPE, COL_TIME}; +use super::super::state::{DealRow, TapeStatus, TicksData, TicksState}; +use super::{order_for, result_pct}; +use moon_core::db::tuner::ticks::{Deal, Deltas, Verdict}; +use moon_core::market::trade_replay::TickStatus; + +fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { + Deal { + report_uid: uid, + core_uid: 1, + strategy_id: 1, + kind: "MoonShot".into(), + coin: "ACE".into(), + buy_ms, + close_ms: buy_ms + 1_000, + buy_price: buy, + sell_price: sell, + spent: 100.0, + is_short: short, + sell_reason: String::new(), + fact_pnl: 0.0, + deltas: Deltas::default(), + tick: None, + } +} + +fn verdict(entry: Option, exit: Option) -> Verdict { + Verdict { + entry, + entry_dev_pct: None, + exit, + exit_dev_pct: None, + fill: None, + exit_kind: None, + line_points: None, + } +} + +fn state() -> TicksState { + let rows = vec![ + DealRow { + deal: deal(1, 3_000, 100.0, 101.0, false), + tape: TapeStatus::Missing, + verdict: None, + address: None, + }, + DealRow { + deal: deal(2, 1_000, 100.0, 99.0, false), + tape: TapeStatus::Covered, + verdict: Some(verdict(Some(true), Some(true))), + address: None, + }, + DealRow { + deal: deal(3, 2_000, 100.0, 99.0, true), + tape: TapeStatus::Refused(TickStatus::NoRoute), + verdict: Some(verdict(Some(false), None)), + address: None, + }, + ]; + let mut state = TicksState::default(); + state.data.apply(Ok(TicksData { + rows, + ..TicksData::default() + })); + state.rows_rev = 1; + state +} + +fn uids(state: &mut TicksState) -> Vec { + let rows: Vec = state + .data + .data() + .map(|d| d.rows.iter().map(|r| r.deal.report_uid).collect()) + .unwrap_or_default(); + order_for(state).iter().map(|&i| rows[i]).collect() +} + +#[test] +fn the_default_order_is_newest_entry_first() { + let mut state = state(); + assert_eq!(state.sort, Some((COL_TIME.to_string(), true))); + assert_eq!(uids(&mut state), [1, 3, 2]); +} + +#[test] +fn a_short_result_is_signed_from_its_own_side() { + let state = state(); + let rows = &state.data.data().unwrap().rows; + assert!((result_pct(&rows[0]) - 1.0).abs() < 1e-9); + assert!((result_pct(&rows[1]) + 1.0).abs() < 1e-9); + assert!( + (result_pct(&rows[2]) - 1.0).abs() < 1e-9, + "a short sold lower won" + ); + let mut state = state; + state.sort = Some((COL_RESULT.to_string(), true)); + let top = uids(&mut state)[0]; + assert!(top == 1 || top == 3); +} + +#[test] +fn tape_and_model_sort_by_rank_and_the_cache_follows_the_sort() { + let mut state = state(); + state.sort = Some((COL_TAPE.to_string(), false)); + assert_eq!(uids(&mut state)[0], 2, "covered first"); + state.sort = Some((COL_MODEL.to_string(), false)); + assert_eq!(uids(&mut state)[0], 2, "both hits first"); + assert_eq!(uids(&mut state)[2], 1, "unanswered last"); + // The cache is keyed by rows_rev and sort: an unchanged pair reuses it. + let before = state.order.as_ref().map(|c| c.order.clone()); + let again: Vec = order_for(&mut state).to_vec(); + assert_eq!(before.as_deref(), Some(again.as_slice())); +} + +#[test] +fn covered_and_fetchable_count_what_the_captions_say() { + let state = state(); + let data = state.data.data().unwrap(); + assert_eq!(data.covered(), 1); + assert_eq!(data.fetchable().count(), 0, "no address, nothing to ask"); + assert!(data.kinds.is_empty() && !data.entry_modelled()); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs new file mode 100644 index 00000000..d03dd40d --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -0,0 +1,284 @@ +//! State of the "Entry/Exit" axis: the scope's deals with what the terminal holds for each +//! (tape covered or not, the model's verdict on the fact), the KPI of the whole scope beside +//! the KPI of the replayable subset, the "now" values of the parameter grid, and the queue of +//! tape fetches the user asked for. +//! +//! Split from the rendering (`ticks/mod.rs`) like every other axis: the load paths write here, +//! the render path only reads. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::load_state::LoadState; +use moon_core::db::tuner::VarStats; +use moon_core::db::tuner::ticks::{Deal, Verdict}; +use moon_core::market::trade_replay::TickStatus; + +/// What the terminal holds for one deal's window. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::analytics::tuner) enum TapeStatus { + /// The prints cover the window: the model ran. + Covered, + /// Nothing, or a hole, inside the window; a fetch may fill it. + Missing, + /// No way to ask: the core is not connected, or the coin resolves to no market of its + /// catalog. The fetch button skips such rows. + NoAddress, + /// A fetch is in flight for this row. + Fetching, + /// A fetch answered without covering the window; the venue's own word on why. + Refused(TickStatus), +} + +/// One deal of the table with everything the replay learned about it. +#[derive(Clone, Debug)] +pub(in crate::analytics::tuner) struct DealRow { + pub(in crate::analytics::tuner) deal: Deal, + pub(in crate::analytics::tuner) tape: TapeStatus, + /// The fact reproduced, or `None` while the tape is missing. + pub(in crate::analytics::tuner) verdict: Option, + /// `(exchange_key, market)` of the deal's core, resolved at load time; `None` is + /// [`TapeStatus::NoAddress`]. + pub(in crate::analytics::tuner) address: Option>, +} + +/// Where a deal's prints live, as the replay worker keys them, plus what a fetch needs. +#[derive(Clone, Debug)] +pub(in crate::analytics::tuner) struct RowAddress { + pub(in crate::analytics::tuner) core_uid: u64, + pub(in crate::analytics::tuner) exchange_key: String, + pub(in crate::analytics::tuner) market: String, + /// The market's price step from the live catalog, when the core reports it. + pub(in crate::analytics::tuner) tick: Option, +} + +/// One "now" cell of the parameter grid over the selected strategies. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::analytics::tuner) enum NowValue { + /// Every selected strategy holds this value (or leaves the field at default). + Same(String), + /// The selected strategies disagree; the grid prints "varies". + Differs, +} + +/// The loaded picture of the axis for one scope. +#[derive(Clone, Debug, Default)] +pub(in crate::analytics::tuner) struct TicksData { + /// Chronological by close, as `read_deals` orders them. + pub(in crate::analytics::tuner) rows: Vec, + /// Scope rows without millisecond stamps — in the Fact column, not in the table. + pub(in crate::analytics::tuner) without_ms: usize, + /// Column 0: the whole scope (the same SQL as every axis' "Fact", stamps or not); column + /// 1: the rows the tape covers. + pub(in crate::analytics::tuner) kpi: Vec, + /// `(hits, answered)` of the entry group over the covered rows. + pub(in crate::analytics::tuner) entry_share: (usize, usize), + /// `(hits, answered)` of the exit group over the covered rows. + pub(in crate::analytics::tuner) exit_share: (usize, usize), + /// Strategy kinds present among the rows, for the entry group's availability. + pub(in crate::analytics::tuner) kinds: Vec, + /// The parameter grid's "now" column, by field key. + pub(in crate::analytics::tuner) now: HashMap, +} + +impl TicksData { + /// Rows whose window the tape covers. + pub(in crate::analytics::tuner) fn covered(&self) -> usize { + self.rows + .iter() + .filter(|r| r.tape == TapeStatus::Covered) + .count() + } + + /// Rows a fetch could still fill: missing, with an address, not already asked. + pub(in crate::analytics::tuner) fn fetchable(&self) -> impl Iterator { + self.rows + .iter() + .filter(|r| r.tape == TapeStatus::Missing && r.address.is_some()) + } + + /// Whether every kind in the scope has an entry model — the Entry group's switch. + pub(in crate::analytics::tuner) fn entry_modelled(&self) -> bool { + !self.kinds.is_empty() + && self + .kinds + .iter() + .all(|k| moon_core::db::tuner::ticks::entry_model_for(k)) + } + + /// The kinds without an entry model, for the group's caption. + pub(in crate::analytics::tuner) fn unmodelled_kinds(&self) -> Vec<&str> { + self.kinds + .iter() + .map(String::as_str) + .filter(|k| !moon_core::db::tuner::ticks::entry_model_for(k)) + .collect() + } +} + +/// The user's fetch request: which rows are queued, which one is in flight. +#[derive(Default)] +pub(in crate::analytics::tuner) struct FetchQueue { + /// `reportuid`s still to ask for, oldest first. + pub(in crate::analytics::tuner) pending: Vec, + /// The row a request is out for. + pub(in crate::analytics::tuner) in_flight: Option, + /// Rows asked for since the button was pressed, for the "N/M" caption. + pub(in crate::analytics::tuner) done: usize, + pub(in crate::analytics::tuner) total: usize, + /// Bumped on every scope change; an answer carrying an older number is dropped. + pub(in crate::analytics::tuner) seq: u64, +} + +impl FetchQueue { + pub(in crate::analytics::tuner) fn is_active(&self) -> bool { + self.in_flight.is_some() || !self.pending.is_empty() + } + + /// Forget everything queued; an answer in flight is retired by the generation. + pub(in crate::analytics::tuner) fn clear(&mut self) { + self.pending.clear(); + self.in_flight = None; + self.done = 0; + self.total = 0; + self.seq = self.seq.wrapping_add(1); + } +} + +/// State of the "Entry/Exit" mode. +pub(in crate::analytics) struct TicksState { + pub(in crate::analytics::tuner) data: LoadState, + /// `TicksData::kpi` under the shape the shared matrix reads; applied together with `data`. + pub(in crate::analytics::tuner) kpi: LoadState>, + /// Generation of the load in flight; an older completion is dropped. + pub(in crate::analytics::tuner) seq: u64, + /// The loaded picture no longer matches the scope or the report generation. + pub(in crate::analytics::tuner) dirty: bool, + /// `(column key, descending)` of the deal table. + pub(in crate::analytics::tuner) sort: Option<(String, bool)>, + /// The sorted row order, cached against `rows_rev` and the sort. + pub(in crate::analytics::tuner) order: Option, + /// Bumped whenever `data` changes, so the cached order is rebuilt. + pub(in crate::analytics::tuner) rows_rev: u64, + /// Whether the two parameter groups are unfolded. + pub(in crate::analytics::tuner) entry_open: bool, + pub(in crate::analytics::tuner) exit_open: bool, + pub(in crate::analytics::tuner) fetch: FetchQueue, +} + +impl Default for TicksState { + fn default() -> Self { + Self { + data: LoadState::default(), + kpi: LoadState::default(), + seq: 0, + dirty: true, + sort: Some((super::columns::COL_TIME.to_string(), true)), + order: None, + rows_rev: 0, + entry_open: true, + exit_open: true, + fetch: FetchQueue::default(), + } + } +} + +impl TicksState { + /// Report-derived numbers are stale; the next entry into the mode reloads them. + pub(in crate::analytics) fn mark_report_stale(&mut self) { + self.dirty = true; + } + + /// The scope changed: every row and the fetch queue belong to the previous scope. + /// + /// A row a fetch was out for goes back to "missing": its answer will be dropped by the + /// queue's generation, and a stale picture kept across a failed reload must not show a + /// fetch that is not running. + pub(in crate::analytics) fn invalidate(&mut self) { + self.dirty = true; + self.seq = self.seq.wrapping_add(1); + self.rows_rev = self.rows_rev.wrapping_add(1); + self.order = None; + self.fetch.clear(); + if let Some(data) = self.data.data_mut() { + for row in &mut data.rows { + if row.tape == TapeStatus::Fetching { + row.tape = TapeStatus::Missing; + } + } + } + } + + /// Whether entering the mode requires a load — mirrors `CoinsState::needs_reload`. + pub(in crate::analytics) fn needs_reload(&self) -> bool { + self.data.data().is_none() || self.dirty + } + + /// Replace one row's replay result in place, after a fetch, keeping the rest. + /// + /// Args: + /// report_uid: The row. + /// update: What the fetch learned. + pub(in crate::analytics::tuner) fn update_row( + &mut self, + report_uid: i64, + update: impl FnOnce(&mut DealRow), + ) { + let Some(data) = self.data.data_mut() else { + return; + }; + let Some(row) = data + .rows + .iter_mut() + .find(|r| r.deal.report_uid == report_uid) + else { + return; + }; + update(row); + data.refresh_summary(); + let kpi = data.kpi.clone(); + // In place as well, so the two load states stay in the same phase: a stale picture + // edited during a reload stays stale in both, and a reload's outcome lands on both. + if let Some(slot) = self.kpi.data_mut() { + *slot = kpi; + } + // The tape and model columns sort by what just changed, so the order is rebuilt; the + // rows themselves stay where they are. + self.rows_rev = self.rows_rev.wrapping_add(1); + self.order = None; + } + + /// Publish one loaded picture (or its failure) to both load states at once. + pub(in crate::analytics::tuner) fn publish( + &mut self, + result: Result, + keep_on_failure: bool, + ) { + let kpi = result.as_ref().map(|d| d.kpi.clone()).map_err(Clone::clone); + self.kpi.apply_or_keep(kpi, keep_on_failure); + self.data.apply_or_keep(result, keep_on_failure); + self.rows_rev = self.rows_rev.wrapping_add(1); + self.order = None; + } +} + +impl TicksData { + /// Recompute the covered-subset KPI (column 1) and the ✓ shares from the rows — after a + /// fetch changed one of them. Column 0, the whole scope, comes from the same SQL every + /// axis' "Fact" comes from and is left as loaded. + pub(in crate::analytics::tuner) fn refresh_summary(&mut self) { + let subset = moon_core::db::tuner::ticks::fact_stats( + self.rows + .iter() + .filter(|r| r.tape == TapeStatus::Covered) + .map(|r| &r.deal), + ); + match self.kpi.get_mut(1) { + Some(slot) => *slot = subset, + None => self.kpi.push(subset), + } + let verdicts = || self.rows.iter().filter_map(|r| r.verdict.as_ref()); + self.entry_share = moon_core::db::tuner::ticks::verify::share(verdicts().map(|v| v.entry)); + self.exit_share = moon_core::db::tuner::ticks::verify::share(verdicts().map(|v| v.exit)); + } +} diff --git a/crates/moon-ui-gpui/src/load_state.rs b/crates/moon-ui-gpui/src/load_state.rs index cbb2b7f2..84d88bb7 100644 --- a/crates/moon-ui-gpui/src/load_state.rs +++ b/crates/moon-ui-gpui/src/load_state.rs @@ -54,6 +54,20 @@ impl LoadState { } } + /// The renderable data for an in-place edit — a row's status after a fetch — without + /// republishing the whole value. Copies on write only when something else still holds + /// the previous snapshot (a render in flight). + pub(crate) fn data_mut(&mut self) -> Option<&mut T> + where + T: Clone, + { + match self { + LoadState::Ready(v) => Some(Arc::make_mut(v)), + LoadState::Loading { stale } => stale.as_mut().map(Arc::make_mut), + LoadState::NotReady | LoadState::Failed(_) => None, + } + } + /// Mark a new request as started, carrying forward only data still worth /// showing. Both completed non-data states drop it. pub(crate) fn begin(&mut self) { diff --git a/crates/moon-ui-gpui/tests/theme_contract/tuner.rs b/crates/moon-ui-gpui/tests/theme_contract/tuner.rs index 2543c135..5f97c4ed 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/tuner.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/tuner.rs @@ -50,10 +50,15 @@ fn tuning_mode_buttons_keep_their_order_and_baseline() { .find(needle) .unwrap_or_else(|| panic!("{needle} must be rendered in the card header")) }; - let (filters, time, coins) = (at("\"sm-filters\""), at("\"sm-time\""), at("\"sm-coins\"")); + let (filters, time, coins, ticks) = ( + at("\"sm-filters\""), + at("\"sm-time\""), + at("\"sm-coins\""), + at("\"sm-ticks\""), + ); assert!( - filters < time && time < coins, - "the axis buttons must read filter, time, coin" + filters < time && time < coins && coins < ticks, + "the axis buttons must read filter, time, coin, entry/exit" ); assert!( at("design::micro_control_h(cx)") < filters, @@ -62,8 +67,8 @@ fn tuning_mode_buttons_keep_their_order_and_baseline() { ); assert_eq!( header.matches("mode_btn(").count(), - 3, - "exactly three axis buttons — a fourth needs this test updated deliberately, not a \ + 4, + "exactly four axis buttons — a fifth needs this test updated deliberately, not a \ copy-pasted line the ordering assertion above would happily accept" ); } diff --git a/locales/analytics.yml b/locales/analytics.yml index 22e4cd3e..35a21150 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1698,3 +1698,149 @@ analytics.recovery_recording_off_detail: ru: "Новые сделки сейчас не сохраняются в этой реплике отчётов. После устранения причины и перезапуска терминала пропуск будет загружен из ядра: метка синхронизации хранится в самой реплике. Не удастся вернуть только сделки, которые ядро уже не хранит." en: "New trades are not being recorded in this reports replica. After fixing the cause and restarting the terminal, the gap will be refilled from the core: the sync checkpoint is stored in the replica itself. Only trades the core no longer retains cannot be recovered." es: "Las nuevas operaciones no se están guardando en esta réplica de informes. Tras corregir la causa y reiniciar el terminal, el historial faltante se descargará del núcleo: el punto de sincronización se guarda en la propia réplica. Solo las operaciones que el núcleo ya no conserva no podrán recuperarse." + +# Tuner: the "Entry/Exit" axis — the trade tape replayed around each closed trade. +analytics.strat.mode_ticks: + ru: "Вход/Выход" + en: "Entry/Exit" + es: "Entrada/Salida" +analytics.ticks.title: + ru: "Сделки" + en: "Trades" + es: "Operaciones" +analytics.ticks.coverage: + ru: "с лентой %{covered} из %{total} · без мс-штампа %{without}" + en: "tape for %{covered} of %{total} · no ms stamp %{without}" + es: "cinta en %{covered} de %{total} · sin marca ms %{without}" +analytics.ticks.fetch_btn: + ru: "Прогрузить трейды" + en: "Fetch trades" + es: "Cargar trades" +analytics.ticks.fetch_progress: + ru: "трейды: %{done}/%{total} · стоп" + en: "trades: %{done}/%{total} · stop" + es: "trades: %{done}/%{total} · parar" +analytics.ticks.col.time: + ru: "вход" + en: "entry" + es: "entrada" +analytics.ticks.col.buy: + ru: "buy" + en: "buy" + es: "buy" +analytics.ticks.col.sell: + ru: "sell" + en: "sell" + es: "sell" +analytics.ticks.col.result: + ru: "%" + en: "%" + es: "%" +analytics.ticks.col.duration: + ru: "длит." + en: "dur." + es: "dur." +analytics.ticks.col.d5s: + ru: "d5s" + en: "d5s" + es: "d5s" +analytics.ticks.col.d1m: + ru: "d1m" + en: "d1m" + es: "d1m" +analytics.ticks.col.d1h: + ru: "d1h" + en: "d1h" + es: "d1h" +analytics.ticks.col.dmark: + ru: "dmark" + en: "dmark" + es: "dmark" +analytics.ticks.col.pricebug: + ru: "pbug" + en: "pbug" + es: "pbug" +analytics.ticks.col.reason: + ru: "причина" + en: "reason" + es: "motivo" +analytics.ticks.col.tape: + ru: "лента" + en: "tape" + es: "cinta" +analytics.ticks.col.model: + ru: "модель" + en: "model" + es: "modelo" +analytics.ticks.tape_covered: + ru: "Лента трейдов покрывает окно сделки" + en: "The trade tape covers the trade's window" + es: "La cinta de trades cubre la ventana de la operación" +analytics.ticks.tape_missing: + ru: "Ленты нет или в ней дыра — «Прогрузить трейды» запросит её у биржи" + en: "No tape, or a hole in it — \"Fetch trades\" asks the venue" + es: "Sin cinta o con un hueco — «Cargar trades» la pedirá al exchange" +analytics.ticks.tape_fetching: + ru: "Запрашивается у биржи…" + en: "Fetching from the venue…" + es: "Pidiendo al exchange…" +analytics.ticks.tape_no_address: + ru: "Ядро не подключено или монета не найдена в его каталоге — запросить не у кого" + en: "The core is not connected, or its catalog has no such market — nothing to ask" + es: "El núcleo no está conectado o su catálogo no tiene el mercado — no hay a quién pedir" +analytics.ticks.tape_refused: + ru: "Биржа не отдала трейды: %{status}" + en: "The venue did not serve the trades: %{status}" + es: "El exchange no entregó los trades: %{status}" +analytics.ticks.model_tip: + ru: "Вход: отклонение модели от факта %{entry} · Выход: %{exit}" + en: "Entry: model vs fact %{entry} · Exit: %{exit}" + es: "Entrada: modelo vs hecho %{entry} · Salida: %{exit}" +analytics.ticks.subset: + ru: "Факт · с лентой" + en: "Fact · with tape" + es: "Hecho · con cinta" +analytics.ticks.subset_sub: + ru: "по %{n} из %{m} · вход ✓ %{entry} · выход ✓ %{exit}" + en: "%{n} of %{m} · entry ✓ %{entry} · exit ✓ %{exit}" + es: "%{n} de %{m} · entrada ✓ %{entry} · salida ✓ %{exit}" +analytics.ticks.params_title: + ru: "Параметры" + en: "Parameters" + es: "Parámetros" +analytics.ticks.params_sub: + ru: "фаза 1: только текущие значения" + en: "phase 1: current values only" + es: "fase 1: solo valores actuales" +analytics.ticks.assumptions: + ru: "Модель не учитывает: стакан и очередь · выход кроме тейка · дельты как константы окна · опора ASK/BID = последний принт стороны · MShotRepeat* · задержка перестановки 100 мс" + en: "Not modelled: the book and the queue · exits other than the take · deltas constant over the window · ASK/BID reference = the last print of that side · MShotRepeat* · a 100 ms replacement latency" + es: "No modelado: libro y cola · salidas salvo el take · deltas constantes en la ventana · referencia ASK/BID = último print del lado · MShotRepeat* · latencia de reemplazo de 100 ms" +analytics.ticks.group_entry: + ru: "Вход" + en: "Entry" + es: "Entrada" +analytics.ticks.group_exit: + ru: "Выход" + en: "Exit" + es: "Salida" +analytics.ticks.entry_from_fact: + ru: "вход для %{kinds} не моделируется — берётся из факта" + en: "the entry of %{kinds} is not modelled — taken from the fact" + es: "la entrada de %{kinds} no se modela — se toma del hecho" +analytics.ticks.group_collapse: + ru: "Свернуть группу" + en: "Collapse the group" + es: "Plegar el grupo" +analytics.ticks.group_expand: + ru: "Развернуть группу" + en: "Expand the group" + es: "Desplegar el grupo" +analytics.ticks.now: + ru: "сейчас" + en: "now" + es: "ahora" +analytics.ticks.no_deals: + ru: "в выборке нет сделок с мс-штампами" + en: "no trades with millisecond stamps in the scope" + es: "no hay operaciones con marcas de ms en el ámbito" From 9bbf168d06261c5f1d0b4ff3c529444cb5095151 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sun, 20 Sep 2026 14:23:44 +0200 Subject: [PATCH 04/51] feat(tuner): variants, search and save for the Entry/Exit axis (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/db/tuner/ticks/mod.rs | 2 +- crates/moon-core/src/db/tuner/ticks/search.rs | 30 +- .../src/db/tuner/ticks/search/tests.rs | 3 +- crates/moon-core/src/db/tuner/ticks/stats.rs | 10 + .../src/db/tuner/ticks/tests/real_data.rs | 5 +- crates/moon-ui-gpui/src/analytics/bg.rs | 8 + .../src/analytics/tuner/filter/state.rs | 2 +- .../moon-ui-gpui/src/analytics/tuner/mod.rs | 7 +- .../moon-ui-gpui/src/analytics/tuner/shell.rs | 38 +- .../src/analytics/tuner/ticks/fetch.rs | 31 +- .../src/analytics/tuner/ticks/grid.rs | 411 ++++++++++++++---- .../src/analytics/tuner/ticks/load.rs | 84 +++- .../src/analytics/tuner/ticks/mod.rs | 230 +++++++++- .../src/analytics/tuner/ticks/rows/tests.rs | 63 +++ .../src/analytics/tuner/ticks/state.rs | 188 +++++++- .../src/analytics/tuner/ticks/variants.rs | 344 +++++++++++++++ locales/analytics.yml | 66 ++- 17 files changed, 1339 insertions(+), 183 deletions(-) create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 1fad2a1c..779d44cc 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -40,7 +40,7 @@ pub use exit::{ExitModel, ExitParams}; pub use mshot::{MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; pub use search::{PreparedDeal, SearchParams, SearchResult, suggest, variant_tally}; -pub use stats::fact_stats; +pub use stats::{fact_stats, stats_of}; pub use verify::{Verdict, verify}; /// Relative tolerance under which a modelled price counts as reproducing the fact: 0.05 %. diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index 0b9d7a9c..03bb1131 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -151,18 +151,29 @@ fn varied<'a>(p: &SearchParams<'a>) -> Vec<&'static super::params::TickParam> { .collect() } -/// The tally of a point over `deals`, in order. -fn tally(deals: &[PreparedDeal], entry: &EntryParams, exit: &ExitParams) -> Tally { +/// The tally of a point over `deals`, in order, and the spend of the deals it traded. +fn tally_and_spent(deals: &[PreparedDeal], entry: &EntryParams, exit: &ExitParams) -> (Tally, f64) { // The replay of every deal is independent; the tally is folded in order afterwards. - let results: Vec> = deals + let results: Vec> = deals .par_iter() - .map(|d| simulate(&d.deal, &d.ticks, entry, exit, d.entry_start).profit_money(&d.deal)) + .map(|d| { + simulate(&d.deal, &d.ticks, entry, exit, d.entry_start) + .profit_money(&d.deal) + .map(|money| (money, d.deal.spent)) + }) .collect(); let mut tally = Tally::default(); - for money in results.into_iter().flatten() { + let mut spent = 0.0; + for (money, size) in results.into_iter().flatten() { tally.push(money); + spent += size; } - tally + (tally, spent) +} + +/// The tally of a point over `deals`, in order. +fn tally(deals: &[PreparedDeal], entry: &EntryParams, exit: &ExitParams) -> Tally { + tally_and_spent(deals, entry, exit).0 } /// Whether `a` beats `b` under the objective, with the sample floor. @@ -330,7 +341,8 @@ pub fn suggest( }) } -/// The KPI of one explicit set of values over `deals` — a variant column. +/// The KPI of one explicit set of values over `deals` — a variant column — and the spend of +/// the deals it traded. /// /// Args: /// deals: The covered deals, chronological. @@ -346,7 +358,7 @@ pub fn variant_tally( kind: &str, values: &[(String, String)], latency_ms: f64, -) -> Tally { +) -> (Tally, f64) { let mut point = Point::new(); for (key, value) in values { if let Some(field) = TICK_PARAMS.iter().find(|f| f.key == key) { @@ -354,7 +366,7 @@ pub fn variant_tally( } } let (entry, exit) = params_of(base, defaults, &point, kind, latency_ms); - install(|| tally(deals, &entry, &exit)) + install(|| tally_and_spent(deals, &entry, &exit)) } #[cfg(test)] diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index d85ee36a..6da2dc8c 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -99,8 +99,9 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { assert!(result.holdout.is_none()); assert_eq!(handle.completed(), 3); // The same values through the variant column. - let tally = variant_tally(&deals, &base, &defaults, "Spread", &result.values, 0.0); + let (tally, spent) = variant_tally(&deals, &base, &defaults, "Spread", &result.values, 0.0); assert!((tally.profit - 80.0).abs() < 1e-6); + assert!((spent - 8_000.0).abs() < 1e-6); } #[test] diff --git a/crates/moon-core/src/db/tuner/ticks/stats.rs b/crates/moon-core/src/db/tuner/ticks/stats.rs index 89045369..3ed93a8d 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats.rs @@ -23,5 +23,15 @@ pub fn fact_stats<'a>(deals: impl IntoIterator) -> VarStats { stats_from_tally(tally, spent) } +/// The KPI of one variant out of its tally and the spend of the deals it traded — the shape +/// the matrix draws, from what the search and the variant columns compute. +/// +/// Args: +/// tally: The variant's results, chronological. +/// spent: Sum of the entry sizes of the deals the variant traded. +pub fn stats_of(tally: Tally, spent: f64) -> VarStats { + stats_from_tally(tally, spent) +} + #[cfg(test)] mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index daba929f..facb95df 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -32,9 +32,12 @@ use crate::symbol::{coin_match_key, coin_of_market}; /// The tape must reach this far back before the buy for the corridor to have a run-up. const RUN_UP_MS: i64 = 30_000; +/// The archived first point of an entry line and every point of an exit line. +type ArchivedLines = (Option<(i64, f64)>, Option>); + /// The archived first point of the deal's own entry line and every point of its own exit /// line, when the archive holds them. -fn archived_lines(deal: &Deal) -> (Option<(i64, f64)>, Option>) { +fn archived_lines(deal: &Deal) -> ArchivedLines { let Ok(entries) = read_many(deal.core_uid, &[deal.report_uid]) else { return (None, None); }; diff --git a/crates/moon-ui-gpui/src/analytics/bg.rs b/crates/moon-ui-gpui/src/analytics/bg.rs index 15a6282d..64c38e27 100644 --- a/crates/moon-ui-gpui/src/analytics/bg.rs +++ b/crates/moon-ui-gpui/src/analytics/bg.rs @@ -35,6 +35,10 @@ pub(super) enum ReadLane { TicksReplay, /// The Entry/Exit axis' per-row tape fetch, one row at a time. TicksFetch, + /// The Entry/Exit axis' variant columns, rescored after an edit. + TicksVariants, + /// The Entry/Exit axis' search. + TicksSearch, } /// Active cancellation tokens keyed by the UI state each request may publish. @@ -141,6 +145,8 @@ impl AnalyticsView { ReadLane::Ticks, ReadLane::TicksReplay, ReadLane::TicksFetch, + ReadLane::TicksVariants, + ReadLane::TicksSearch, ]); } @@ -156,6 +162,8 @@ impl AnalyticsView { ReadLane::Ticks, ReadLane::TicksReplay, ReadLane::TicksFetch, + ReadLane::TicksVariants, + ReadLane::TicksSearch, ]); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/filter/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/filter/state.rs index 80da6f5e..0de85362 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/filter/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/filter/state.rs @@ -150,7 +150,7 @@ fn restore_edges_upto(saved: Option, max: usize) -> usize { pub(in crate::analytics::tuner) const TRAIN_OPTIONS: [usize; 6] = [100, 90, 80, 70, 60, 50]; /// Train share used when nothing is chosen or a stored value is not on offer. -pub(super) const DEFAULT_TRAIN: usize = 100; +pub(in crate::analytics::tuner) const DEFAULT_TRAIN: usize = 100; /// Return `v` when the dropdown offers it, otherwise the default share. pub(super) fn train_of(v: usize) -> usize { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs index afab2e16..a1d84013 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs @@ -725,15 +725,16 @@ impl AnalyticsView { ); } StratMode::Ticks if !side_collapsed => { - // The matrix on top, the parameter grid below — the same right column as the - // filter axis, with the grid read-only until phase 2. + // The matrix on top, the parameter grid with its search row below — the same + // right column as the filter axis. + let side = self.ticks_side(p, window, cx); main = main.child( v_flex() .w(design::font_w_px(cx, 470.0)) .flex_none() .h_full() .min_h_0() - .child(self.ticks_side(p, cx)), + .child(side), ); } StratMode::Filters | StratMode::Coins | StratMode::Ticks => {} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/shell.rs b/crates/moon-ui-gpui/src/analytics/tuner/shell.rs index d5fcf4a8..adb0d0a6 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/shell.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/shell.rs @@ -35,7 +35,7 @@ const SETTINGS_POPUP_W: f32 = 250.0; /// Which settings box of a suggestion row is being built. #[derive(Clone, Copy, PartialEq)] -enum CfgInput { +pub(super) enum CfgInput { /// Restart count of the joint search ("By filter" only). Restarts, /// Minimum trades a suggestion must retain. @@ -92,7 +92,7 @@ fn short_seed(seed: u64) -> String { /// /// 100% is spelled out as "off" rather than shown as a number, because a percentage that happens /// to be the whole period reads as a setting in effect when it is the absence of one. -fn train_label(pct: usize) -> String { +pub(super) fn train_label(pct: usize) -> String { if pct >= 100 { t!("analytics.tuner.train_off").to_string() } else { @@ -177,10 +177,8 @@ impl AnalyticsView { ); } // Render write controls for a retained anchor, but enable them only when the action - // authority admits at least one selected target. The tape axis has nothing to write - // until its variants land (phase 2), so it shows no write controls at all rather than - // two buttons that do nothing. - if self.sel_strategy.is_some() && kind != TunerKind::Ticks { + // authority admits at least one selected target. + if self.sel_strategy.is_some() { let workspace_target_visible = self.visible_target_count(self.action_core_ids()) > 0; // Copy is single-target only — hidden in multi-select (many addressees, no // per-target preview); bulk Save is the multi path. @@ -196,7 +194,7 @@ impl AnalyticsView { TunerKind::Filter => this.open_copy_dialog(window, cx), TunerKind::Time => this.time_open_copy_dialog(window, cx), TunerKind::Coins => this.coins_open_copy_dialog(window, cx), - TunerKind::Ticks => {} + TunerKind::Ticks => this.ticks_open_copy_dialog(window, cx), } cx.notify(); })) @@ -213,7 +211,8 @@ impl AnalyticsView { // The list differs from what the strategies hold — the same condition // the coin table's "changed" badge and its Revert button read. TunerKind::Coins => self.coins.has_changes(), - TunerKind::Ticks => false, + // The first variant column holds something to write. + TunerKind::Ticks => self.ticks.has_changes(), }; MoonButton::new(SharedString::from(format!("tun-save-{k}"))) .variant(if dirty { @@ -228,7 +227,7 @@ impl AnalyticsView { TunerKind::Filter => this.open_save_dialog(cx), TunerKind::Time => this.time_open_save_dialog(cx), TunerKind::Coins => this.coins_open_save_dialog(cx), - TunerKind::Ticks => {} + TunerKind::Ticks => this.ticks_open_save_dialog(cx), } cx.notify(); })) @@ -256,7 +255,8 @@ impl AnalyticsView { // The coin axis does not draw this row yet — its own controls arrive with the // selection metrics. Answering here keeps the match exhaustive rather than letting a // fourth axis compile into a silent default. - TunerKind::Coins | TunerKind::Ticks => div().into_any_element(), + TunerKind::Ticks => self.ticks_config_row(p, window, cx), + TunerKind::Coins => div().into_any_element(), } } @@ -846,7 +846,7 @@ impl AnalyticsView { } /// A suggestion settings box for the `kind` axis, with a lazy cache in that axis's state. - fn shell_cfg_input( + pub(super) fn shell_cfg_input( &mut self, kind: TunerKind, which: CfgInput, @@ -858,7 +858,8 @@ impl AnalyticsView { let cached = match kind { TunerKind::Filter => self.tuner.inputs.get(id), TunerKind::Time => self.time_tuner.inputs.get(id), - TunerKind::Coins | TunerKind::Ticks => None, + TunerKind::Ticks => self.ticks.inputs.get(id), + TunerKind::Coins => None, }; if let Some(state) = cached { return state.clone(); @@ -868,8 +869,10 @@ impl AnalyticsView { (TunerKind::Filter, CfgInput::Seed) => self.tuner.seed.clone(), (TunerKind::Filter, CfgInput::Restarts) => self.tuner.iters.clone(), (TunerKind::Time, CfgInput::MinTrades) => self.time_tuner.min_trades.clone(), - // The time row draws only the minimum-trades box, and the coin axis draws no row at - // all, so neither has a value for the rest. + (TunerKind::Ticks, CfgInput::Restarts) => self.ticks.iters.clone(), + (TunerKind::Ticks, CfgInput::MinTrades) => self.ticks.min_trades.clone(), + // The time row draws only the minimum-trades box, the tape axis no seed box, and + // the coin axis draws no row at all, so none has a value for the rest. (TunerKind::Time, _) | (TunerKind::Coins, _) | (TunerKind::Ticks, _) => String::new(), }; let ph = placeholder.to_string(); @@ -924,6 +927,10 @@ impl AnalyticsView { this.time_tuner.min_trades = value; this.time_tuner.invalidate_suggest(); } + (TunerKind::Ticks, CfgInput::Restarts) => this.ticks.iters = value, + (TunerKind::Ticks, CfgInput::MinTrades) => { + this.ticks.min_trades = value; + } (TunerKind::Time, _) | (TunerKind::Coins, _) | (TunerKind::Ticks, _) => {} } if !matches!(ev, MoonInputEvent::Change) { @@ -936,7 +943,8 @@ impl AnalyticsView { match kind { TunerKind::Filter => self.tuner.inputs.insert(id.to_string(), state.clone()), TunerKind::Time => self.time_tuner.inputs.insert(id.to_string(), state.clone()), - TunerKind::Coins | TunerKind::Ticks => None, + TunerKind::Ticks => self.ticks.inputs.insert(id.to_string(), state.clone()), + TunerKind::Coins => None, }; state } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index fdceb67a..c232effe 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -15,12 +15,11 @@ use std::sync::mpsc; use gpui::*; use super::super::super::AnalyticsView; -use super::load::replay_row; +use super::load::{ArchivedLines, replay_row}; use super::state::TapeStatus; use crate::analytics::bg::ReadLane; -use moon_core::db::order_traces::{TraceEntry, read_many}; +use moon_core::db::order_traces::read_many; use moon_core::db::tuner::ticks::Deal; -use moon_core::feed::report_traces::ArchivedLineKind; use moon_core::market::trade_replay::worker::{self, TradeReplayRequest}; use moon_core::market::trade_replay::{ TickStatus, TradeReplayOutcome, margin_ms, replay_window_ms, @@ -138,14 +137,16 @@ impl AnalyticsView { } }; } - let entry_start = archived_entry_start(&deal); + let lines = archived_lines_of(&deal); let mut row = super::state::DealRow { deal, tape: TapeStatus::Missing, verdict: None, address: Some(address), + ticks: None, + entry_start: None, }; - replay_row(&mut row, &defaults, entry_start); + replay_row(&mut row, &defaults, lines); if row.tape == TapeStatus::Missing { row.tape = match status { TickStatus::Served | TickStatus::Pending | TickStatus::Streaming => { @@ -165,8 +166,12 @@ impl AnalyticsView { slot.tape = row.tape; slot.verdict = row.verdict; slot.deal.tick = row.deal.tick; + slot.ticks = row.ticks; + slot.entry_start = row.entry_start; }); this.ticks.fetch.done += 1; + // A row joined the replayable set: the variant columns are due a rescore. + this.arm_ticks_variants(cx); this.ticks_fetch_next(cx); }, ); @@ -185,14 +190,10 @@ fn fetch_identity(report_uid: i64) -> u64 { hash | 1 } -/// The archived first point of one deal's own entry line. -fn archived_entry_start(deal: &Deal) -> Option<(i64, f64)> { - let entries = read_many(deal.core_uid, &[deal.report_uid]).ok()?; - match entries.get(&deal.report_uid)? { - TraceEntry::Lines(lines) => lines - .iter() - .find(|l| l.own && l.kind == ArchivedLineKind::Entry) - .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))), - TraceEntry::Empty { .. } => None, - } +/// The archived lines of one deal. +fn archived_lines_of(deal: &Deal) -> ArchivedLines { + read_many(deal.core_uid, &[deal.report_uid]) + .ok() + .and_then(|entries| entries.get(&deal.report_uid).map(ArchivedLines::of)) + .unwrap_or_default() } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index 0a741d6b..894ef23e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -1,31 +1,49 @@ //! The parameter grid of the "Entry/Exit" axis: the two groups of `TICK_PARAMS`, each behind -//! its own caret, with the "now" value of the selected strategies beside every field. +//! its own caret and its own "vary" switch, with the "now" value of the selected strategies, +//! the variant columns В1/В2 as input boxes, and a "fix" tick per field that holds it out of +//! the search. //! -//! Phase 1 is read-only: the variant columns and the "vary" switches arrive with the search. -//! The Entry group is shown only when every kind in the scope has an entry model; otherwise it -//! folds to one line saying whose entry is taken from the fact. +//! A group's "vary" switch is locked while the model does not reproduce enough of the fact +//! for that group (`SHARE_GATE`): searching over a model that cannot replay what happened +//! optimizes noise, and the switch says so in its tooltip. The Entry group is shown only when +//! every kind in the scope has an entry model; otherwise it folds to one line saying whose +//! entry is taken from the fact. use gpui::*; -use moon_ui::{MoonPalette, h_flex, v_flex}; +use moon_ui::{ + MoonButton, MoonButtonVariant, MoonCheckbox, MoonInput, MoonInputEvent, MoonInputState, + MoonPalette, MoonTooltipView, h_flex, v_flex, +}; use rust_i18n::t; use super::super::super::AnalyticsView; -use super::super::shared::{card, collapse_caret}; -use super::state::{NowValue, TicksData}; +use super::super::shared::{N_VAR, TunerKind, collapse_caret}; +use super::state::{NowValue, SHARE_GATE, TicksData}; use crate::design; use crate::design::{moon, moon_alpha}; use moon_core::db::tuner::ticks::params::{ParamGroup, TickParam, params_for}; +/// Width of the "now" and variant cells, font-scaled px. +const CELL_W: f32 = 72.0; +/// Width of the "fix" tick cell. +const FIX_W: f32 = 28.0; + impl AnalyticsView { - /// The grid card: the assumptions line under the title, then the two groups. + /// The grid panel: the shared toolbar (title, Copy, Save), the search row, then the + /// assumptions line and the two groups, scrolling. pub(in crate::analytics::tuner) fn ticks_grid( - &self, + &mut self, p: MoonPalette, - cx: &Context, + window: &mut Window, + cx: &mut Context, ) -> AnyElement { + let header = self.shell_toolbar( + TunerKind::Ticks, + t!("analytics.ticks.params_title").to_string(), + cx, + ); + let cfg_row = self.shell_config_row(TunerKind::Ticks, p, window, cx); let data = self.ticks.data.data().cloned(); - let entry_open = self.ticks.entry_open; - let exit_open = self.ticks.exit_open; let mut body = v_flex().w_full().flex_none(); // What the model does not know, in one line — the spec's §4.4, kept where the user // reads the numbers the assumptions shape. @@ -59,55 +77,105 @@ impl AnalyticsView { .to_string(), ) }; + let entry_open = self.ticks.entry_open && entry_modelled; + let exit_open = self.ticks.exit_open; body = body.child(self.ticks_group( - "an-ticks-grp-entry", ParamGroup::Entry, t!("analytics.ticks.group_entry").to_string(), entry_note, - entry_open && entry_modelled, + entry_open, entry_modelled, data.as_deref(), - |this| this.ticks.entry_open = !this.ticks.entry_open, p, + window, cx, )); body = body.child(self.ticks_group( - "an-ticks-grp-exit", ParamGroup::Exit, t!("analytics.ticks.group_exit").to_string(), None, exit_open, true, data.as_deref(), - |this| this.ticks.exit_open = !this.ticks.exit_open, p, + window, cx, )); - card( - t!("analytics.ticks.params_title").to_string(), - t!("analytics.ticks.params_sub").to_string(), - body.into_any_element(), - None, - p, - cx, - ) + let tools = self.ticks_grid_tools(cx); + v_flex() + .w_full() + .flex_1() + .min_h_0() + .rounded(design::ui_px(cx, 8.0)) + .bg(moon(p.panel)) + .border_1() + .border_color(moon(p.border)) + .overflow_hidden() + .child(header) + .child(cfg_row) + .child( + div() + .id("an-ticks-grid-scroll") + .w_full() + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child(body), + ) + .child( + h_flex() + .w_full() + .flex_none() + .px(design::ui_px(cx, 12.0)) + .py(design::ui_px(cx, 6.0)) + .justify_end() + .child(tools), + ) + .into_any_element() } - /// One group: a header line with its caret, then a row per field when unfolded. + /// The card's accessory: "В1 → В2" and the two "clear" buttons. + fn ticks_grid_tools(&self, cx: &Context) -> AnyElement { + h_flex() + .gap(design::ui_px(cx, 4.0)) + .font_family(design::ui_font()) + .child( + MoonButton::new("an-ticks-v1-to-v2") + .variant(MoonButtonVariant::Soft) + .label(t!("analytics.ticks.v1_to_v2").to_string()) + .disabled(!self.ticks.has_changes()) + .on_click(cx.listener(|this, _, _, cx| this.ticks_copy_v1_to_v2(cx))) + .render(), + ) + .children((0..N_VAR).map(|i| { + MoonButton::new(SharedString::from(format!("an-ticks-clear-v{i}"))) + .variant(MoonButtonVariant::Soft) + .label(t!("analytics.ticks.clear_v", n = i + 1).to_string()) + .disabled(self.ticks.variants[i].is_empty()) + .on_click(cx.listener(move |this, _, _, cx| this.ticks_clear_variant(i, cx))) + .render() + })) + .into_any_element() + } + + /// One group: a header line with its switch and caret, then a row per field. #[allow(clippy::too_many_arguments)] fn ticks_group( - &self, - id: &'static str, + &mut self, group: ParamGroup, title: String, note: Option, open: bool, enabled: bool, data: Option<&TicksData>, - toggle: impl Fn(&mut Self) + 'static, p: MoonPalette, - cx: &Context, + window: &mut Window, + cx: &mut Context, ) -> AnyElement { + let (id, checkbox_id) = match group { + ParamGroup::Entry => ("an-ticks-grp-entry", "an-ticks-vary-entry"), + ParamGroup::Exit => ("an-ticks-grp-exit", "an-ticks-vary-exit"), + }; let caret = collapse_caret( id, !open, @@ -115,10 +183,62 @@ impl AnalyticsView { t!("analytics.ticks.group_expand").to_string(), p, cx.listener(move |this, _, _, cx| { - toggle(this); + match group { + ParamGroup::Entry => this.ticks.entry_open = !this.ticks.entry_open, + ParamGroup::Exit => this.ticks.exit_open = !this.ticks.exit_open, + } cx.notify(); }), ); + // The "vary" switch: on by the user, allowed by the gate. + let passes = data.and_then(|d| d.group_passes(group)); + let share = data + .map(|d| match group { + ParamGroup::Entry => d.entry_share, + ParamGroup::Exit => d.exit_share, + }) + .unwrap_or((0, 0)); + let gated = enabled && passes == Some(true); + let vary_on = match group { + ParamGroup::Entry => self.ticks.vary_entry, + ParamGroup::Exit => self.ticks.vary_exit, + }; + let vary_tip = if gated { + t!("analytics.ticks.vary_tip").to_string() + } else if passes == Some(false) { + t!( + "analytics.ticks.vary_gated", + hits = share.0, + n = share.1, + gate = (SHARE_GATE * 100.0) as i64 + ) + .to_string() + } else { + t!("analytics.ticks.vary_unknown").to_string() + }; + let vary = div() + .id(SharedString::from(format!("{checkbox_id}-box"))) + .flex_none() + .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(vary_tip.clone())).into()) + .child( + MoonCheckbox::new(SharedString::from(checkbox_id)) + .label(t!("analytics.ticks.vary").to_string()) + .checked(vary_on && gated) + .disabled(!gated) + .on_change({ + let view = cx.entity(); + move |on: &bool, _w, app| { + let on = *on; + view.update(app, |this, cx| { + match group { + ParamGroup::Entry => this.ticks.vary_entry = on, + ParamGroup::Exit => this.ticks.vary_exit = on, + } + cx.notify(); + }); + } + }), + ); let mut head = h_flex() .w_full() .px(design::ui_px(cx, 12.0)) @@ -139,8 +259,8 @@ impl AnalyticsView { }) .child(title), ); - if let Some(note) = note { - head = head.child( + head = match note { + Some(note) => head.child( div() .flex_1() .min_w_0() @@ -148,12 +268,11 @@ impl AnalyticsView { .text_size(design::t_caption(cx)) .text_color(moon(p.text_muted)) .child(note), - ); - } else { - head = head.child(div().flex_1()); - } + ), + None => head.child(div().flex_1()), + }; if enabled { - head = head.child(div().flex_none().child(caret)); + head = head.child(vary).child(div().flex_none().child(caret)); } let mut out = v_flex().w_full().flex_none().child(head); if !(open && enabled) { @@ -161,9 +280,7 @@ impl AnalyticsView { } // The fields every kind in the scope understands: the union over the kinds present, // in descriptor order. - let kinds: Vec<&str> = data - .map(|d| d.kinds.iter().map(String::as_str).collect()) - .unwrap_or_default(); + let kinds: Vec = data.map(|d| d.kinds.clone()).unwrap_or_default(); let fields: Vec<&'static TickParam> = moon_core::db::tuner::ticks::TICK_PARAMS .iter() .filter(|f| f.group == group) @@ -173,57 +290,173 @@ impl AnalyticsView { .any(|k| params_for(group, k).any(|g| g.key == f.key)) }) .collect(); - out = out.child( - h_flex() - .w_full() - .px(design::ui_px(cx, 12.0)) - .py(design::ui_px(cx, 2.0)) - .text_size(design::t_caption(cx)) - .text_color(moon(p.text_soft)) - .child( - div() - .flex_1() - .child(t!("analytics.tuner.field").to_string()), - ) - .child( - div() - .w(design::font_w_px(cx, 96.0)) - .flex_none() - .text_right() - .child(t!("analytics.ticks.now").to_string()), - ), - ); - for field in fields { - let now = data.and_then(|d| d.now.get(field.key)); - let (text, muted) = match now { - Some(NowValue::Same(v)) if !v.is_empty() => (v.clone(), false), - Some(NowValue::Same(_)) | None => ("—".to_string(), true), - Some(NowValue::Differs) => (t!("analytics.time.cur_varies").to_string(), true), - }; - out = out.child( - h_flex() - .w_full() - .px(design::ui_px(cx, 12.0)) - .py(design::ui_px(cx, 2.0)) - .items_center() - .border_t_1() - .border_color(moon_alpha(p.border, 0.5)) - .text_size(design::t_body(cx)) - .child(div().flex_1().min_w_0().truncate().child(field.key)) + out = out.child(self.ticks_grid_header(p, cx)); + let now: Vec<(&'static str, Option)> = fields + .iter() + .map(|f| (f.key, data.and_then(|d| d.now.get(f.key).cloned()))) + .collect(); + for (key, now) in now { + out = out.child(self.ticks_field_row(key, now, p, window, cx)); + } + out.into_any_element() + } + + /// The column headings of a group: field · now · В1 · В2 · fix. + fn ticks_grid_header(&self, p: MoonPalette, cx: &Context) -> AnyElement { + let cell = |text: String| { + div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .text_right() + .child(text) + }; + h_flex() + .w_full() + .px(design::ui_px(cx, 12.0)) + .py(design::ui_px(cx, 2.0)) + .gap(design::ui_px(cx, 6.0)) + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_soft)) + .child( + div() + .flex_1() + .child(t!("analytics.tuner.field").to_string()), + ) + .child(cell(t!("analytics.ticks.now").to_string())) + .children((0..N_VAR).map(|i| cell(t!("analytics.ticks.var_n", n = i + 1).to_string()))) + .child( + div() + .w(design::font_w_px(cx, FIX_W)) + .flex_none() + .text_center() + .child(t!("analytics.ticks.fix").to_string()), + ) + .into_any_element() + } + + /// One field: its key, the "now" value, an input per variant, the "fix" tick. + fn ticks_field_row( + &mut self, + key: &'static str, + now: Option, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (text, muted) = match now { + Some(NowValue::Same(v)) if !v.is_empty() => (v, false), + Some(NowValue::Same(_)) | None => ("—".to_string(), true), + Some(NowValue::Differs) => (t!("analytics.time.cur_varies").to_string(), true), + }; + let inputs: Vec> = (0..N_VAR) + .map(|i| self.ticks_cell_input(i, key, window, cx)) + .collect(); + let locked = self.ticks.locked.contains(key); + let mut row = h_flex() + .w_full() + .px(design::ui_px(cx, 12.0)) + .py(design::ui_px(cx, 2.0)) + .items_center() + .gap(design::ui_px(cx, 6.0)) + .border_t_1() + .border_color(moon_alpha(p.border, 0.5)) + .text_size(design::t_body(cx)) + .child(div().flex_1().min_w_0().truncate().child(key)) + .child( + div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .text_right() + .text_color(if muted { + moon(p.text_muted) + } else { + moon(p.text) + }) + .child(text), + ); + for (i, input) in inputs.iter().enumerate() { + row = row.child( + div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .font_family(design::mono()) .child( - div() - .w(design::font_w_px(cx, 96.0)) - .flex_none() - .text_right() - .text_color(if muted { - moon(p.text_muted) - } else { - moon(p.text) - }) - .child(text), + MoonInput::new(SharedString::from(format!("an-ticks-in-v{i}-{key}"))) + .state(input) + .size(design::INPUT_SIZE), ), ); } - out.into_any_element() + row = row.child( + div() + .w(design::font_w_px(cx, FIX_W)) + .flex_none() + .flex() + .justify_center() + .child( + MoonCheckbox::new(SharedString::from(format!("an-ticks-fix-{key}"))) + .checked(locked) + .on_change({ + let view = cx.entity(); + move |on: &bool, _w, app| { + let on = *on; + view.update(app, |this, cx| { + if on { + this.ticks.locked.insert(key.to_string()); + } else { + this.ticks.locked.remove(key); + } + cx.notify(); + }); + } + }), + ), + ); + row.into_any_element() + } + + /// The input box of one variant cell, created on first use from the stored value and + /// kept across repaints; a change stores the value and rescores the columns. + fn ticks_cell_input( + &mut self, + index: usize, + key: &'static str, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let id = format!("v{index}:{key}"); + if let Some(state) = self.ticks.inputs.get(&id) { + return state.clone(); + } + let value = self.ticks.variants[index] + .get(key) + .cloned() + .unwrap_or_default(); + let state = cx.new(|cx| MoonInputState::new(window, cx).default_value(value)); + cx.subscribe_in( + &state, + window, + move |this, state, ev: &MoonInputEvent, _window, cx| { + if matches!( + ev, + MoonInputEvent::Change + | MoonInputEvent::Blur + | MoonInputEvent::PressEnter { .. } + ) { + let value = state.read(cx).value().to_string(); + if this.ticks.variants[index].get(key).map(String::as_str) + != Some(value.as_str()) + { + this.set_ticks_variant(index, key, value, cx); + } + if !matches!(ev, MoonInputEvent::Change) { + cx.notify(); + } + } + }, + ) + .detach(); + self.ticks.inputs.insert(id, state.clone()); + state } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index ad81979b..d7657df9 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -70,10 +70,18 @@ impl AnalyticsView { if !after_report { self.report_busy_retries.reset(); } - self.latest_reads - .cancel(&[ReadLane::Ticks, ReadLane::TicksReplay, ReadLane::TicksFetch]); + self.latest_reads.cancel(&[ + ReadLane::Ticks, + ReadLane::TicksReplay, + ReadLane::TicksFetch, + ReadLane::TicksVariants, + ReadLane::TicksSearch, + ]); self.ticks.seq = self.ticks.seq.wrapping_add(1); self.ticks.fetch.clear(); + // A search over the previous deal set answers nothing about the new one; the lane + // cancel above does not reach its handle, only this does. + self.ticks.stop_search(); let req = self.ticks.seq; let report_req = self.current_report_generation(); let q = self.tuner_query(); @@ -207,17 +215,20 @@ impl AnalyticsView { }, verdict: None, address, + ticks: None, + entry_start: None, } }) .collect(); - let traces = archived_entry_starts(&rows); + let mut traces = archived_lines(&rows); for row in &mut rows { // Each row waits on the worker; a scope change cancels this lane, and the // wait is not a statement the progress handler could interrupt. if moon_core::db::current_is_cancelled() { break; } - replay_row(row, &defaults, traces.get(&row.deal.report_uid).copied()); + let lines = traces.remove(&row.deal.report_uid).unwrap_or_default(); + replay_row(row, &defaults, lines); } let mut kinds: Vec = rows.iter().map(|r| r.deal.kind.clone()).collect(); kinds.sort(); @@ -231,6 +242,7 @@ impl AnalyticsView { kinds, now, }; + data.retain_within_cap(); data.refresh_summary(); data }, @@ -241,6 +253,8 @@ impl AnalyticsView { this.ticks.dirty = report_result_is_stale(report_req, this.current_report_generation(), false); this.ticks.publish(Ok(data), false); + // The replayable set may have changed under the variant columns: rescore them. + this.arm_ticks_variants(cx); if after_report { this.settle_report_refresh_retry(false, cx); } @@ -276,8 +290,37 @@ fn now_values(targets: &[(i64, Option)], keys: &[String]) -> HashMap HashMap { +/// What the order archive holds of one deal's own lines: the entry line's first point and +/// the exit line's points. +#[derive(Clone, Debug, Default)] +pub(super) struct ArchivedLines { + pub(super) entry_start: Option<(i64, f64)>, + pub(super) exit_points: Option>, +} + +impl ArchivedLines { + /// Read from one archived entry. + pub(super) fn of(entry: &TraceEntry) -> Self { + let TraceEntry::Lines(lines) = entry else { + return Self::default(); + }; + let entry_start = lines + .iter() + .find(|l| l.own && l.kind == ArchivedLineKind::Entry) + .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))); + let exit_points = lines + .iter() + .find(|l| l.own && l.kind == ArchivedLineKind::Exit) + .map(|l| l.points.iter().map(|&(t, p)| (t as i64, p)).collect()); + Self { + entry_start, + exit_points, + } + } +} + +/// The archived lines of every deal, read once per core. +fn archived_lines(rows: &[DealRow]) -> HashMap { let mut by_core: HashMap> = HashMap::new(); for row in rows { by_core @@ -291,14 +334,7 @@ fn archived_entry_starts(rows: &[DealRow]) -> HashMap { continue; }; for (uid, entry) in entries { - if let TraceEntry::Lines(lines) = entry - && let Some(start) = lines - .iter() - .find(|l| l.own && l.kind == ArchivedLineKind::Entry) - .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))) - { - out.insert(uid, start); - } + out.insert(uid, ArchivedLines::of(&entry)); } } out @@ -324,12 +360,10 @@ pub(super) fn held_tape( } /// Run the model on one row, from what the worker holds; a row without an address is left -/// as it is. -pub(super) fn replay_row( - row: &mut DealRow, - defaults: &HashMap, - entry_start: Option<(i64, f64)>, -) { +/// as it is. A covered row keeps its tape and its archived entry start for the variants. +pub(super) fn replay_row(row: &mut DealRow, defaults: &HashMap, lines: ArchivedLines) { + row.ticks = None; + row.entry_start = lines.entry_start; let Some(address) = row.address.clone() else { return; }; @@ -371,5 +405,13 @@ pub(super) fn replay_row( EntryParams::Fact }; let exit = params::exit_params(&sv); - row.verdict = Some(verify(&row.deal, &ticks, &entry, &exit, entry_start, None)); + row.verdict = Some(verify( + &row.deal, + &ticks, + &entry, + &exit, + lines.entry_start, + lines.exit_points.as_deref(), + )); + row.ticks = Some(Arc::from(ticks)); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index 31f3cc75..cad78876 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -4,9 +4,8 @@ //! Left, 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. Right: the shared "Fact vs …" matrix (the whole -//! scope beside the replayable subset, captioned with the ✓ shares) and the parameter grid. -//! Phase 1 stops there — the variant columns and the search land with phase 2 — and says so -//! in its captions rather than hiding the gap. +//! scope, the replayable subset captioned with the ✓ shares, the variant columns), and the +//! parameter grid with the strategies' values, the two variant columns and the search row. //! //! The model itself is `moon_core::db::tuner::ticks`; this module only feeds it and draws //! what it says. @@ -21,10 +20,13 @@ use rust_i18n::t; use super::super::AnalyticsView; use super::kpi::{VarLabel, kpi_matrix_card}; +use super::shared::TunerKind; +use super::shell::CfgInput; use super::{sort_arrow_of, toggle_sort_key}; use crate::design; use crate::design::{moon, moon_alpha}; use columns::*; +use state::SuggState; use state::{DealRow, TapeStatus}; pub(in crate::analytics::tuner) mod columns; @@ -33,6 +35,7 @@ mod grid; mod load; pub(in crate::analytics::tuner) mod rows; pub(in crate::analytics) mod state; +mod variants; impl AnalyticsView { /// The deal table card — sits UNDER the strategy list, where the coin table sits in "By @@ -244,38 +247,42 @@ impl AnalyticsView { })) } - /// The right column of the axis: the matrix on top, the grid below it, scrolling as one. + /// The right column of the axis: the matrix on top, the grid panel below it. pub(in crate::analytics::tuner) fn ticks_side( - &self, + &mut self, p: MoonPalette, - cx: &Context, + window: &mut Window, + cx: &mut Context, ) -> AnyElement { + let kpi = self.ticks_kpi(p, cx); + let grid = self.ticks_grid(p, window, cx); v_flex() .w_full() .h_full() .min_h_0() .gap(design::ui_px(cx, 8.0)) - .child(self.ticks_kpi(p, cx)) - .child( - div() - .id("an-ticks-grid-scroll") - .w_full() - .flex_1() - .min_h_0() - .overflow_y_scroll() - .child(self.ticks_grid(p, cx)), - ) + .child(kpi) + .child(grid) .into_any_element() } - /// "Fact vs …": the whole scope beside the rows the tape covers, the second captioned - /// with the ✓ shares of both groups — the model's own account of itself. + /// "Fact vs …": the whole scope, the rows the tape covers (captioned with the ✓ shares of + /// both groups — the model's own account of itself), then the variant columns, each over + /// the replayable rows and captioned with how many. fn ticks_kpi(&self, p: MoonPalette, cx: &Context) -> AnyElement { - let (covered, total, entry, exit) = self + let (covered, total, entry, exit, replayable) = self .ticks .data .data() - .map(|d| (d.covered(), d.rows.len(), d.entry_share, d.exit_share)) + .map(|d| { + ( + d.covered(), + d.rows.len(), + d.entry_share, + d.exit_share, + d.replayable().count(), + ) + }) .unwrap_or_default(); let share = |(hits, n): (usize, usize)| -> String { if n == 0 { @@ -284,7 +291,7 @@ impl AnalyticsView { format!("{:.0} %", hits as f64 / n as f64 * 100.0) } }; - let labels = vec![VarLabel::with_sub( + let mut labels = vec![VarLabel::with_sub( t!("analytics.ticks.subset").to_string(), t!( "analytics.ticks.subset_sub", @@ -295,9 +302,63 @@ impl AnalyticsView { ) .to_string(), )]; - // `TicksState::kpi` is `TicksData::kpi` — `[fact, subset]` — under the matrix's shape. + // The matrix reads one vector: `[fact, subset]` from the load, then the variants that + // were scored. An untouched variant is not a column. + let mut stats: Vec = self + .ticks + .kpi + .data() + .map(|k| k.to_vec()) + .unwrap_or_default(); + for (i, var) in self.ticks.var_stats.iter().enumerate() { + let Some(var) = var else { + continue; + }; + let mut sub = t!( + "analytics.ticks.var_sub", + n = var.n, + m = self.ticks.var_n.max(replayable) + ) + .to_string(); + if i == 0 { + if let Some(holdout) = self + .ticks + .last_result + .as_ref() + .and_then(|r| r.holdout.as_ref()) + { + sub = format!( + "{sub} · {}", + t!( + "analytics.ticks.holdout", + n = holdout.n, + profit = super::super::summary::fmt_signed(holdout.profit) + ) + ); + } + } + labels.push(VarLabel::with_sub( + t!("analytics.ticks.var_n", n = i + 1).to_string(), + sub, + )); + stats.push(var.clone()); + } + let state = match &self.ticks.kpi { + crate::load_state::LoadState::Ready(_) => { + crate::load_state::LoadState::Ready(std::sync::Arc::new(stats)) + } + crate::load_state::LoadState::Loading { stale } => { + crate::load_state::LoadState::Loading { + stale: stale.as_ref().map(|_| std::sync::Arc::new(stats)), + } + } + crate::load_state::LoadState::NotReady => crate::load_state::LoadState::NotReady, + crate::load_state::LoadState::Failed(e) => { + crate::load_state::LoadState::Failed(e.clone()) + } + }; kpi_matrix_card( - &self.ticks.kpi, + &state, self.scope_label(), &labels, self.kpi_collapsed, @@ -305,6 +366,129 @@ impl AnalyticsView { cx, ) } + + /// The search row of the axis: restarts, minimum trades, the train share, the status, + /// Stop and "Search". + pub(in crate::analytics::tuner) fn ticks_config_row( + &mut self, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let running = matches!(self.ticks.sugg, SuggState::Running { .. }); + let (status, status_color) = match &self.ticks.sugg { + // Counted against the restarts the run was launched with, not the box's current + // text. + SuggState::Running { handle, total } => ( + t!( + "analytics.tuner.sugg_progress", + done = handle.completed(), + total = total + ) + .to_string(), + p.text_soft, + ), + SuggState::Idle => match &self.ticks.sugg_note { + Some(note) => (note.clone(), p.amber), + None => (String::new(), p.text_muted), + }, + }; + let it_placeholder = variants::DEFAULT_RESTARTS.to_string(); + let it_input = self.shell_cfg_input( + TunerKind::Ticks, + CfgInput::Restarts, + &it_placeholder, + window, + cx, + ); + let mn_input = + self.shell_cfg_input(TunerKind::Ticks, CfgInput::MinTrades, "auto", window, cx); + let train_pct = self.ticks.train_pct; + let tr_view = cx.entity(); + let tr_items = crate::panels::radio_items( + super::filter::state::TRAIN_OPTIONS.map(|n| { + ( + n, + SharedString::from(format!("tun-tr-x-{n}")), + SharedString::from(super::shell::train_label(n)), + ) + }), + train_pct, + crate::panels::RadioMark::Highlight, + move |app, n| { + tr_view.update(app, |this, cx| { + this.ticks.train_pct = n; + cx.notify(); + }); + }, + ); + let tr_combo = moon_ui::MoonDropdown::new(SharedString::from("tun-cfg-tr-x")) + .label(super::shell::train_label(train_pct)) + .trigger_caret(true) + .trigger_variant(MoonButtonVariant::Soft) + .trigger_size(moon_ui::MoonButtonSize::density(cx)) + .menu_width_scaled(96.0) + .items(tr_items); + let input_box = |id: &'static str, state: &Entity, w: f32| { + div() + .w(design::font_w_px(cx, w)) + .flex_none() + .font_family(design::mono()) + .child( + moon_ui::MoonInput::new(SharedString::from(id)) + .state(state) + .size(design::INPUT_SIZE), + ) + }; + h_flex() + .w_full() + .flex_none() + .px(design::ui_px(cx, 12.0)) + .pb(design::ui_px(cx, 6.0)) + .items_center() + .gap(design::ui_px(cx, 6.0)) + .text_size(design::t_caption(cx)) + .font_family(design::ui_font()) + .child( + div() + .text_color(moon(p.text_muted)) + .child(t!("analytics.tuner.iters").to_string()), + ) + .child(input_box("tun-cfg-it-x", &it_input, 46.0)) + .child( + div() + .text_color(moon(p.text_muted)) + .child(t!("analytics.tuner.min_trades").to_string()), + ) + .child(input_box("tun-cfg-mn-x", &mn_input, 46.0)) + .child(tr_combo) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(moon(status_color)) + .child(status), + ) + .when(running, |el| { + el.child( + MoonButton::new("tun-suggest-stop-x") + .variant(MoonButtonVariant::Soft) + .label(t!("analytics.tuner.stop").to_string()) + .on_click(cx.listener(|this, _, _, cx| this.ticks_stop_suggest(cx))) + .render(), + ) + }) + .child( + MoonButton::new("tun-suggest-run-x") + .variant(MoonButtonVariant::Blue) + .label(t!("analytics.tuner.suggest_run").to_string()) + .disabled(running) + .on_click(cx.listener(|this, _, _, cx| this.ticks_suggest(cx))) + .render(), + ) + .into_any_element() + } } /// Height of one deal row, in base px — the single pitch the list and the row share. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index e0b2f032..e4f839ed 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -43,18 +43,24 @@ fn state() -> TicksState { tape: TapeStatus::Missing, verdict: None, address: None, + ticks: None, + entry_start: None, }, DealRow { deal: deal(2, 1_000, 100.0, 99.0, false), tape: TapeStatus::Covered, verdict: Some(verdict(Some(true), Some(true))), address: None, + ticks: None, + entry_start: None, }, DealRow { deal: deal(3, 2_000, 100.0, 99.0, true), tape: TapeStatus::Refused(TickStatus::NoRoute), verdict: Some(verdict(Some(false), None)), address: None, + ticks: None, + entry_start: None, }, ]; let mut state = TicksState::default(); @@ -120,3 +126,60 @@ fn covered_and_fetchable_count_what_the_captions_say() { assert_eq!(data.fetchable().count(), 0, "no address, nothing to ask"); assert!(data.kinds.is_empty() && !data.entry_modelled()); } + +// ---- the variant edits and the search gate ---------------------------------------------------- + +#[test] +fn variant_edits_fold_to_sorted_changes_and_empty_cells_clear() { + let mut state = TicksState::default(); + assert!(!state.has_changes()); + state.set_variant(0, "SellPrice", " 0.5 ".into()); + state.set_variant(0, "MShotPrice", "2".into()); + state.set_variant(1, "StopLoss", "-1".into()); + assert_eq!( + state.variant_changes(0), + vec![ + ("MShotPrice".to_string(), "2".to_string()), + ("SellPrice".to_string(), "0.5".to_string()), + ] + ); + assert!(state.has_changes()); + state.set_variant(0, "MShotPrice", " ".into()); + assert_eq!(state.variant_changes(0).len(), 1, "a blank clears the cell"); + assert_eq!(state.variant_changes(1).len(), 1); +} + +#[test] +fn the_share_gate_answers_per_group_and_only_once_something_answered() { + use moon_core::db::tuner::ticks::params::ParamGroup; + let mut data = TicksData::default(); + assert_eq!(data.group_passes(ParamGroup::Entry), None); + data.entry_share = (8, 10); + data.exit_share = (7, 10); + assert_eq!(data.group_passes(ParamGroup::Entry), Some(true)); + assert_eq!(data.group_passes(ParamGroup::Exit), Some(false)); + data.kinds = vec!["MoonShot".into()]; + assert_eq!(data.single_kind(), Some("MoonShot")); + data.kinds.push("Spread".into()); + assert_eq!(data.single_kind(), None); +} + +#[test] +fn invalidate_stops_the_search_and_drops_the_variant_scores_but_keeps_the_edits() { + let mut state = state(); + state.set_variant(0, "SellPrice", "1".into()); + state.var_stats[0] = Some(moon_core::db::tuner::VarStats::default()); + let handle = moon_core::db::tuner::threshold_search::SearchHandle::new(); + state.sugg = super::super::state::SuggState::Running { + handle: handle.clone(), + total: 3, + }; + state.invalidate(); + assert!(handle.is_cancelled()); + assert!(matches!(state.sugg, super::super::state::SuggState::Idle)); + assert!(state.var_stats[0].is_none()); + assert!( + state.has_changes(), + "the user's edits survive a scope change" + ); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index d03dd40d..095d9415 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -6,14 +6,32 @@ //! Split from the rendering (`ticks/mod.rs`) like every other axis: the load paths write here, //! the render path only reads. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use gpui::Entity; +use moon_ui::MoonInputState; + +use super::super::shared::N_VAR; use crate::load_state::LoadState; use moon_core::db::tuner::VarStats; +use moon_core::db::tuner::threshold_search::SearchHandle; +use moon_core::db::tuner::ticks::params::ParamGroup; +use moon_core::db::tuner::ticks::search::SearchResult; use moon_core::db::tuner::ticks::{Deal, Verdict}; +use moon_core::feed::types::Tick; use moon_core::market::trade_replay::TickStatus; +/// Share of hits a group needs before it may be searched: a model that cannot reproduce the +/// fact must not be asked what would have been better. The spec's proposal (80 %), to be tuned +/// by practice. +pub(in crate::analytics::tuner) const SHARE_GATE: f64 = 0.8; + +/// Ticks kept in memory across every covered row, for the variants and the search. Past it a +/// row is still "covered" — the model ran on it — but its tape is let go and the row sits out +/// of the variant columns; the caption says how many. +pub(in crate::analytics::tuner) const MAX_RETAINED_TICKS: usize = 4_000_000; + /// What the terminal holds for one deal's window. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(in crate::analytics::tuner) enum TapeStatus { @@ -40,6 +58,11 @@ pub(in crate::analytics::tuner) struct DealRow { /// `(exchange_key, market)` of the deal's core, resolved at load time; `None` is /// [`TapeStatus::NoAddress`]. pub(in crate::analytics::tuner) address: Option>, + /// The window's prints, kept for the variants and the search while the row is covered and + /// the memory cap allows; `None` otherwise. + pub(in crate::analytics::tuner) ticks: Option>, + /// The archived first point of the entry line, when the archive holds it. + pub(in crate::analytics::tuner) entry_start: Option<(i64, f64)>, } /// Where a deal's prints live, as the replay worker keys them, plus what a fetch needs. @@ -97,6 +120,32 @@ impl TicksData { .filter(|r| r.tape == TapeStatus::Missing && r.address.is_some()) } + /// Covered rows whose tape is in memory — what the variants and the search replay. + pub(in crate::analytics::tuner) fn replayable(&self) -> impl Iterator { + self.rows + .iter() + .filter(|r| r.tape == TapeStatus::Covered && r.ticks.is_some()) + } + + /// The share gate per group: whether the model reproduces enough of the fact to be + /// searched over. `None` when nothing answered yet. + pub(in crate::analytics::tuner) fn group_passes(&self, group: ParamGroup) -> Option { + let (hits, n) = match group { + ParamGroup::Entry => self.entry_share, + ParamGroup::Exit => self.exit_share, + }; + (n > 0).then(|| hits as f64 / n as f64 >= SHARE_GATE) + } + + /// The one kind of the scope, when there is exactly one; the search needs one to know + /// which fields exist. + pub(in crate::analytics::tuner) fn single_kind(&self) -> Option<&str> { + match self.kinds.as_slice() { + [kind] => Some(kind.as_str()), + _ => None, + } + } + /// Whether every kind in the scope has an entry model — the Entry group's switch. pub(in crate::analytics::tuner) fn entry_modelled(&self) -> bool { !self.kinds.is_empty() @@ -145,9 +194,58 @@ impl FetchQueue { } } +/// The search of the axis, as far as the row shows it. +pub(in crate::analytics::tuner) enum SuggState { + Idle, + /// A run in flight: its handle for the stop button and the progress caption, and the + /// restart count it was LAUNCHED with — the box stays editable while it runs, and the + /// caption must count against what is actually running. + Running { + handle: SearchHandle, + total: usize, + }, +} + +impl Drop for TicksState { + /// A search outlives nothing: closing the window while one runs would otherwise leave it + /// on the shared pool to the end, its answer going nowhere. + fn drop(&mut self) { + self.stop_search(); + } +} + /// State of the "Entry/Exit" mode. pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) data: LoadState, + /// The variant columns' edits: field key to value in strategy spelling. An empty map is + /// an untouched column, drawn as the base. + pub(in crate::analytics::tuner) variants: [HashMap; N_VAR], + /// The KPI of each variant over the replayable rows, `None` until computed or while the + /// variant is untouched. + pub(in crate::analytics::tuner) var_stats: [Option; N_VAR], + /// How many replayable rows the variant KPIs were computed over, for their captions. + pub(in crate::analytics::tuner) var_n: usize, + /// Generation of the variant KPI recompute; a stale completion is dropped. + pub(in crate::analytics::tuner) var_seq: u64, + /// The pending debounced recompute; dropping it cancels it. + pub(in crate::analytics::tuner) var_task: Option>, + /// The grid's and the row's input boxes, created lazily and kept across repaints. + pub(in crate::analytics::tuner) inputs: HashMap>, + /// Which groups the search may vary. + pub(in crate::analytics::tuner) vary_entry: bool, + pub(in crate::analytics::tuner) vary_exit: bool, + /// Fields held at their base value by the search. + pub(in crate::analytics::tuner) locked: HashSet, + /// The search settings, as typed. + pub(in crate::analytics::tuner) iters: String, + pub(in crate::analytics::tuner) min_trades: String, + pub(in crate::analytics::tuner) train_pct: usize, + pub(in crate::analytics::tuner) sugg: SuggState, + pub(in crate::analytics::tuner) sugg_seq: u64, + /// What the last completed search found, for the holdout caption. + pub(in crate::analytics::tuner) last_result: Option, + /// The last search's failure to say anything, for the status line. + pub(in crate::analytics::tuner) sugg_note: Option, /// `TicksData::kpi` under the shape the shared matrix reads; applied together with `data`. pub(in crate::analytics::tuner) kpi: LoadState>, /// Generation of the load in flight; an older completion is dropped. @@ -170,6 +268,22 @@ impl Default for TicksState { fn default() -> Self { Self { data: LoadState::default(), + variants: Default::default(), + var_stats: Default::default(), + var_n: 0, + var_seq: 0, + var_task: None, + inputs: HashMap::new(), + vary_entry: true, + vary_exit: true, + locked: HashSet::new(), + iters: String::new(), + min_trades: String::new(), + train_pct: super::super::filter::state::DEFAULT_TRAIN, + sugg: SuggState::Idle, + sugg_seq: 0, + last_result: None, + sugg_note: None, kpi: LoadState::default(), seq: 0, dirty: true, @@ -200,6 +314,14 @@ impl TicksState { self.rows_rev = self.rows_rev.wrapping_add(1); self.order = None; self.fetch.clear(); + // The variant KPIs and a running search describe the previous scope's deals; the + // variant EDITS are the user's and stay, to be rescored over the new scope. + self.var_seq = self.var_seq.wrapping_add(1); + self.var_task = None; + self.var_stats = Default::default(); + self.stop_search(); + // A note about the previous scope's search says nothing about this one. + self.sugg_note = None; if let Some(data) = self.data.data_mut() { for row in &mut data.rows { if row.tape == TapeStatus::Fetching { @@ -214,6 +336,50 @@ impl TicksState { self.data.data().is_none() || self.dirty } + /// Ask a running search to stop and forget it; its completion is dropped by the + /// generation. + pub(in crate::analytics::tuner) fn stop_search(&mut self) { + if let SuggState::Running { handle, .. } = &self.sugg { + handle.cancel(); + } + self.sugg = SuggState::Idle; + self.sugg_seq = self.sugg_seq.wrapping_add(1); + } + + /// The variant's changes over the base as `(key, value)` pairs, sorted — what Save writes + /// and what the KPI is computed for. + pub(in crate::analytics::tuner) fn variant_changes( + &self, + index: usize, + ) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = self.variants[index] + .iter() + .filter(|(_, v)| !v.trim().is_empty()) + .map(|(k, v)| (k.clone(), v.trim().to_string())) + .collect(); + out.sort(); + out + } + + /// Whether the first variant holds anything to write. + pub(in crate::analytics::tuner) fn has_changes(&self) -> bool { + !self.variant_changes(0).is_empty() + } + + /// Set one cell of a variant; an empty value clears it. + pub(in crate::analytics::tuner) fn set_variant( + &mut self, + index: usize, + key: &str, + value: String, + ) { + if value.trim().is_empty() { + self.variants[index].remove(key); + } else { + self.variants[index].insert(key.to_string(), value); + } + } + /// Replace one row's replay result in place, after a fetch, keeping the rest. /// /// Args: @@ -235,6 +401,7 @@ impl TicksState { return; }; update(row); + data.retain_within_cap(); data.refresh_summary(); let kpi = data.kpi.clone(); // In place as well, so the two load states stay in the same phase: a stale picture @@ -263,6 +430,25 @@ impl TicksState { } impl TicksData { + /// Let go of the tapes past the memory cap, oldest rows first — the newest deals are the + /// ones a variant is most likely to be judged on. A row whose tape is dropped stays + /// covered; it simply sits out of the variant columns. Run after every change of the + /// retained set: the bulk load and each fetched row. + pub(in crate::analytics::tuner) fn retain_within_cap(&mut self) { + let mut held = 0usize; + // Newest first: the rows are chronological, so walk them backwards. + for row in self.rows.iter_mut().rev() { + let Some(ticks) = row.ticks.as_ref() else { + continue; + }; + if held + ticks.len() > MAX_RETAINED_TICKS { + row.ticks = None; + } else { + held += ticks.len(); + } + } + } + /// Recompute the covered-subset KPI (column 1) and the ✓ shares from the rows — after a /// fetch changed one of them. Column 0, the whole scope, comes from the same SQL every /// axis' "Fact" comes from and is left as loaded. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs new file mode 100644 index 00000000..de6e9ef0 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -0,0 +1,344 @@ +//! The variant columns and the search of the "Entry/Exit" axis: the edits behind В1/В2, their +//! debounced rescore over the replayable rows, the search that fills В1, and the write of В1 +//! through the shared confirmation dialog. +//! +//! Every score here is a replay — `variant_tally` over the rows whose tape is in memory — so +//! the columns describe the SAME subset the "Fact · with tape" column describes, never the +//! whole scope. The captions say "by N" for that reason. + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use gpui::*; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +use super::super::shared::N_VAR; +use super::state::{NowValue, SuggState}; +use crate::analytics::bg::ReadLane; +use moon_core::db::tuner::threshold_search::SearchHandle; +use moon_core::db::tuner::ticks::mshot::DEFAULT_LATENCY_MS; +use moon_core::db::tuner::ticks::params::ParamGroup; +use moon_core::db::tuner::ticks::search::{PreparedDeal, SearchParams, suggest, variant_tally}; +use moon_core::db::tuner::ticks::stats_of; + +/// How long a burst of cell edits may keep coalescing before the columns are rescored. +const VARIANT_DEBOUNCE: Duration = Duration::from_millis(350); + +/// Restarts the search runs with, out of the box's text: the default when empty or +/// unreadable, clamped to a sane range. +pub(super) fn restarts_of(text: &str) -> usize { + text.trim() + .parse::() + .unwrap_or(DEFAULT_RESTARTS) + .clamp(1, 10_000) +} + +/// Restarts when the box is empty. +pub(super) const DEFAULT_RESTARTS: usize = 20; + +/// The base every variant is laid over: the fields the selected strategies agree on. +fn base_of(now: &HashMap) -> HashMap { + now.iter() + .filter_map(|(key, value)| match value { + NowValue::Same(v) if !v.is_empty() => Some((key.clone(), v.clone())), + _ => None, + }) + .collect() +} + +impl AnalyticsView { + /// The replayable rows as the search and the columns take them. + fn prepared_deals(&self) -> Vec { + self.ticks + .data + .data() + .map(|d| { + d.replayable() + .filter_map(|row| { + Some(PreparedDeal { + deal: row.deal.clone(), + ticks: row.ticks.clone()?, + entry_start: row.entry_start, + }) + }) + .collect() + }) + .unwrap_or_default() + } + + /// Arm a debounced rescore of the variant columns — every edit of a cell, every row that + /// joins the replayable set, goes through here. + pub(in crate::analytics::tuner) fn arm_ticks_variants(&mut self, cx: &mut Context) { + self.latest_reads.cancel(&[ReadLane::TicksVariants]); + self.ticks.var_seq = self.ticks.var_seq.wrapping_add(1); + // Nothing to score: an untouched pair of columns costs no clone of the rows and no + // replay — a fetch over hundreds of rows re-arms this once per row. + if (0..N_VAR).all(|i| self.ticks.variant_changes(i).is_empty()) { + self.ticks.var_stats = Default::default(); + return; + } + let req = self.ticks.var_seq; + self.ticks.var_task = Some(cx.spawn(async move |this, cx| { + let executor = cx.update(|cx| cx.background_executor().clone()); + executor.timer(VARIANT_DEBOUNCE).await; + let _ = cx.update(|cx| { + let _ = this.update(cx, |this, cx| { + if this.ticks.var_seq == req { + this.run_ticks_variants(req, cx); + } + }); + }); + })); + } + + /// Score every touched variant over the replayable rows. + fn run_ticks_variants(&mut self, req: u64, cx: &mut Context) { + let deals = self.prepared_deals(); + let Some(data) = self.ticks.data.data() else { + return; + }; + let base = base_of(&data.now); + let kind = data.single_kind().unwrap_or_default().to_string(); + let changes: Vec> = + (0..N_VAR).map(|i| self.ticks.variant_changes(i)).collect(); + let defaults = self.filter_defaults(cx); + let n = deals.len(); + self.spawn_latest_db( + &[ReadLane::TicksVariants], + false, + cx, + move || { + changes + .iter() + .map(|values| { + if values.is_empty() || deals.is_empty() { + return None; + } + let (tally, spent) = variant_tally( + &deals, + &base, + &defaults, + &kind, + values, + DEFAULT_LATENCY_MS, + ); + Some(stats_of(tally, spent)) + }) + .collect::>() + }, + move |this, stats, cx| { + if this.ticks.var_seq != req { + return; + } + for (slot, value) in this.ticks.var_stats.iter_mut().zip(stats) { + *slot = value; + } + this.ticks.var_n = n; + cx.notify(); + }, + ); + } + + /// One cell of a variant changed: store it and rescore. + pub(in crate::analytics::tuner) fn set_ticks_variant( + &mut self, + index: usize, + key: &str, + value: String, + cx: &mut Context, + ) { + self.ticks.set_variant(index, key, value); + self.arm_ticks_variants(cx); + } + + /// Copy В1 into В2, so a found point can be kept while another is tried. + pub(in crate::analytics::tuner) fn ticks_copy_v1_to_v2(&mut self, cx: &mut Context) { + self.ticks.variants[1] = self.ticks.variants[0].clone(); + self.ticks_reset_inputs_of(1); + self.arm_ticks_variants(cx); + cx.notify(); + } + + /// Clear one variant column. + pub(in crate::analytics::tuner) fn ticks_clear_variant( + &mut self, + index: usize, + cx: &mut Context, + ) { + self.ticks.variants[index].clear(); + self.ticks.var_stats[index] = None; + self.ticks_reset_inputs_of(index); + self.arm_ticks_variants(cx); + cx.notify(); + } + + /// Drop the input boxes of a variant column so they are recreated from the stored values. + fn ticks_reset_inputs_of(&mut self, index: usize) { + let prefix = format!("v{index}:"); + self.ticks.inputs.retain(|id, _| !id.starts_with(&prefix)); + } + + /// Whether a group may be searched: the user's switch, the kind's support, and the share + /// gate. + pub(in crate::analytics::tuner) fn ticks_group_searchable(&self, group: ParamGroup) -> bool { + let Some(data) = self.ticks.data.data() else { + return false; + }; + let supported = match group { + ParamGroup::Entry => data.entry_modelled(), + ParamGroup::Exit => true, + }; + supported && data.group_passes(group) == Some(true) + } + + /// Run the search into В1. + pub(in crate::analytics::tuner) fn ticks_suggest(&mut self, cx: &mut Context) { + if matches!(self.ticks.sugg, SuggState::Running { .. }) { + return; + } + let deals = self.prepared_deals(); + let Some(data) = self.ticks.data.data() else { + return; + }; + let Some(kind) = data.single_kind().map(String::from) else { + self.ticks.sugg_note = Some(t!("analytics.ticks.sugg_one_kind").to_string()); + cx.notify(); + return; + }; + let vary_entry = self.ticks.vary_entry && self.ticks_group_searchable(ParamGroup::Entry); + let vary_exit = self.ticks.vary_exit && self.ticks_group_searchable(ParamGroup::Exit); + if !(vary_entry || vary_exit) { + self.ticks.sugg_note = Some(t!("analytics.ticks.sugg_nothing").to_string()); + cx.notify(); + return; + } + if deals.is_empty() { + self.ticks.sugg_note = Some(t!("analytics.ticks.sugg_no_tape").to_string()); + cx.notify(); + return; + } + let base = base_of(&data.now); + let defaults = self.filter_defaults(cx); + let locked: HashSet = self.ticks.locked.clone(); + let restarts = restarts_of(&self.ticks.iters); + let min_n = self + .ticks + .min_trades + .trim() + .parse::() + .ok() + .filter(|n| *n > 0); + let train_frac = super::super::filter::state::train_frac(self.ticks.train_pct); + let handle = SearchHandle::new(); + self.ticks.sugg = SuggState::Running { + handle: handle.clone(), + total: restarts, + }; + self.ticks.sugg_seq = self.ticks.sugg_seq.wrapping_add(1); + self.ticks.sugg_note = None; + let seq = self.ticks.sugg_seq; + self.spawn_latest_db( + &[ReadLane::TicksSearch], + false, + cx, + move || { + let params = SearchParams { + base: &base, + defaults: &defaults, + kind: &kind, + vary_entry, + vary_exit, + locked: &locked, + restarts, + min_n, + seed: None, + train_frac, + latency_ms: DEFAULT_LATENCY_MS, + }; + suggest(&deals, ¶ms, &handle) + }, + move |this, result, cx| { + if this.ticks.sugg_seq != seq { + return; + } + this.ticks.sugg = SuggState::Idle; + match result { + Some(result) => { + this.ticks.variants[0] = + result.values.iter().cloned().collect::>(); + this.ticks_reset_inputs_of(0); + this.ticks.last_result = Some(result); + this.arm_ticks_variants(cx); + } + None => { + this.ticks.sugg_note = Some(t!("analytics.ticks.sugg_none").to_string()); + } + } + cx.notify(); + }, + ); + cx.notify(); + } + + /// Stop a running search; what it found so far is dropped. + pub(in crate::analytics::tuner) fn ticks_stop_suggest(&mut self, cx: &mut Context) { + self.ticks.stop_search(); + cx.notify(); + } + + /// "Save": write В1's changes to the selected strategies through the shared dialog. + pub(in crate::analytics::tuner) fn ticks_open_save_dialog(&mut self, cx: &mut Context) { + let targets = self.selected_targets(); + if targets.is_empty() { + return; + } + let changes = self.ticks.variant_changes(0); + if changes.is_empty() { + log::info!("analytics: 'Save' (ticks) - no variant to write"); + return; + } + let warns = self.ticks_change_warnings(&changes); + self.open_change_dialog(targets, changes, None, Vec::new(), warns, false, cx); + } + + /// "Make a copy": a new strategy with В1's changes. + pub(in crate::analytics::tuner) fn ticks_open_copy_dialog( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let Some(target) = self.selected_targets().into_iter().next() else { + return; + }; + let changes = self.ticks.variant_changes(0); + let warns = self.ticks_change_warnings(&changes); + self.open_copy_with(target, changes, warns, window, cx); + } + + /// The honesty line of a write: a closer `MShotPrice` is UNDERESTIMATED by the sample + /// (spikes the real order never reached are not in the report), so the dialog says so. + fn ticks_change_warnings(&self, changes: &[(String, String)]) -> Vec { + let mut warns = Vec::new(); + let base_price = self + .ticks + .data + .data() + .and_then(|d| match d.now.get("MShotPrice") { + Some(NowValue::Same(v)) => v.replace(',', ".").parse::().ok(), + _ => None, + }); + if let (Some(base), Some((_, value))) = + (base_price, changes.iter().find(|(k, _)| k == "MShotPrice")) + { + if value + .replace(',', ".") + .parse::() + .is_ok_and(|v| v < base) + { + warns.push(t!("analytics.ticks.closer_warn").to_string()); + } + } + warns + } +} diff --git a/locales/analytics.yml b/locales/analytics.yml index 35a21150..cc1a4325 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1809,9 +1809,9 @@ analytics.ticks.params_title: en: "Parameters" es: "Parámetros" analytics.ticks.params_sub: - ru: "фаза 1: только текущие значения" - en: "phase 1: current values only" - es: "fase 1: solo valores actuales" + ru: "сейчас · варианты · подбор" + en: "now · variants · search" + es: "ahora · variantes · búsqueda" analytics.ticks.assumptions: ru: "Модель не учитывает: стакан и очередь · выход кроме тейка · дельты как константы окна · опора ASK/BID = последний принт стороны · MShotRepeat* · задержка перестановки 100 мс" en: "Not modelled: the book and the queue · exits other than the take · deltas constant over the window · ASK/BID reference = the last print of that side · MShotRepeat* · a 100 ms replacement latency" @@ -1844,3 +1844,63 @@ analytics.ticks.no_deals: ru: "в выборке нет сделок с мс-штампами" en: "no trades with millisecond stamps in the scope" es: "no hay operaciones con marcas de ms en el ámbito" +analytics.ticks.var_n: + ru: "В%{n}" + en: "V%{n}" + es: "V%{n}" +analytics.ticks.var_sub: + ru: "по %{n} из %{m} с лентой" + en: "%{n} of %{m} with tape" + es: "%{n} de %{m} con cinta" +analytics.ticks.holdout: + ru: "holdout: %{n} сделок, %{profit}" + en: "holdout: %{n} trades, %{profit}" + es: "holdout: %{n} operaciones, %{profit}" +analytics.ticks.fix: + ru: "фикс." + en: "fix" + es: "fijo" +analytics.ticks.vary: + ru: "перебирать" + en: "vary" + es: "variar" +analytics.ticks.vary_tip: + ru: "Подбор перебирает поля этой группы (кроме фиксированных)" + en: "The search varies this group's fields (except the fixed ones)" + es: "La búsqueda varía los campos de este grupo (salvo los fijados)" +analytics.ticks.vary_gated: + ru: "Модель воспроизводит факт лишь в %{hits} из %{n} сделок — меньше %{gate} %, подбирать по ней нельзя" + en: "The model reproduces the fact on only %{hits} of %{n} trades — under %{gate} %, so it cannot be searched over" + es: "El modelo reproduce el hecho solo en %{hits} de %{n} operaciones — menos del %{gate} %, no se puede buscar sobre él" +analytics.ticks.vary_unknown: + ru: "Доля воспроизведения ещё не посчитана — нет сделок с лентой" + en: "The reproduction share is not known yet — no trades with tape" + es: "La cuota de reproducción aún no se conoce — no hay operaciones con cinta" +analytics.ticks.v1_to_v2: + ru: "В1 → В2" + en: "V1 → V2" + es: "V1 → V2" +analytics.ticks.clear_v: + ru: "очистить В%{n}" + en: "clear V%{n}" + es: "limpiar V%{n}" +analytics.ticks.sugg_one_kind: + ru: "Подбор — только по стратегиям одного вида" + en: "The search needs strategies of one kind" + es: "La búsqueda necesita estrategias de un solo tipo" +analytics.ticks.sugg_nothing: + ru: "Нечего перебирать: обе группы выключены или заблокированы" + en: "Nothing to vary: both groups are off or gated" + es: "Nada que variar: ambos grupos están apagados o bloqueados" +analytics.ticks.sugg_no_tape: + ru: "Нет сделок с лентой в памяти" + en: "No trades with tape in memory" + es: "No hay operaciones con cinta en memoria" +analytics.ticks.sugg_none: + ru: "Подбор ничего не нашёл" + en: "The search found nothing" + es: "La búsqueda no encontró nada" +analytics.ticks.closer_warn: + ru: "MShotPrice ближе фактического: оценка занижена — прострелы, до которых реальный ордер не дотянулся, в отчёте отсутствуют" + en: "MShotPrice closer than the fact: underestimated — spikes the real order never reached are not in the report" + es: "MShotPrice más cerca que el hecho: subestimado — los picos que la orden real nunca alcanzó no están en el informe" From 1df2a8001395a982ba0d85a768b0f7f6e17b7219 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sun, 20 Sep 2026 18:02:45 +0200 Subject: [PATCH 05/51] feat(tuner): background tape fetch for the Entry/Exit axis, held tapes in one batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- crates/moon-core/src/db/tuner/ticks/mod.rs | 35 +- crates/moon-core/src/db/tuner/ticks/tests.rs | 1 + .../src/db/tuner/ticks/tests/real_data.rs | 38 +- .../src/db/tuner/ticks/tests/required.rs | 65 +++ crates/moon-core/src/diagnostics/filter.rs | 14 +- .../moon-core/src/diagnostics/filter/tests.rs | 11 + crates/moon-core/src/diagnostics/mod.rs | 4 +- .../moon-core/src/market/trade_replay/gate.rs | 23 + .../moon-core/src/market/trade_replay/mod.rs | 23 +- .../src/market/trade_replay/tests.rs | 23 +- .../src/market/trade_replay/worker.rs | 31 +- crates/moon-ui-gpui/src/analytics/bg.rs | 4 - .../src/analytics/tuner/ticks/fetch.rs | 304 ++++++------ .../src/analytics/tuner/ticks/fetch/job.rs | 459 ++++++++++++++++++ .../analytics/tuner/ticks/fetch/job/tests.rs | 37 ++ .../src/analytics/tuner/ticks/load.rs | 258 ++++++++-- .../src/analytics/tuner/ticks/mod.rs | 27 +- .../src/analytics/tuner/ticks/state.rs | 91 ++-- locales/analytics.yml | 16 +- 19 files changed, 1140 insertions(+), 324 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/tests/required.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 779d44cc..db8f63cc 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -23,6 +23,7 @@ //! checked against the live `strategies.sqlite` field names on 2026-09-20. use crate::feed::types::Tick; +use crate::market::trade_replay::Coverage; pub mod deals; pub mod entry; @@ -206,11 +207,43 @@ pub enum EntryParams { MoonShot(MshotParams), } +/// The tape must reach this far back before the buy for the corridor to have a run-up. +pub const RUN_UP_MS: i64 = 30_000; + +/// The tape must reach this far past the close for the exit to have a tail: a line the fact +/// crossed at the close is reproduced by a print at or before it, but a near miss a few seconds +/// later must be judged on its price — [`verify`] counts a model still open when the tape ends +/// as a miss of the exit group, and a tape cut at the close would turn every such near miss +/// into one. +pub const TAIL_MS: i64 = 30_000; + +/// The part of a deal's window the model cannot do without: the run-up before the buy through +/// the tail after the close, clipped to what the window asks for at all (a long position asks +/// only around its two ends, and a zero margin asks for the position alone). The rest of the +/// window — the trail beyond the tail, the lead beyond the run-up — is served as far as the +/// tape goes: a venue's page budget runs out on the trail of a pumped coin long before the +/// margin, and a variant that outlives the tape is marked open at the window's end. +/// +/// Args: +/// deal: The deal, for its buy and close stamps. +/// spans: The window's focus spans, as asked from the worker. +/// +/// Returns: +/// The spans the held coverage must include for the deal to count as covered. +pub fn required_spans(deal: &Deal, spans: &Coverage) -> Coverage { + Coverage::one(( + deal.buy_ms.saturating_sub(RUN_UP_MS), + deal.close_ms.saturating_add(TAIL_MS), + )) + .clip(spans) +} + /// Run one trade through the entry and the exit model. /// /// `ticks` is the tape the caller fetched for the deal's window, ascending by time; it must /// reach back before `deal.buy_ms` for the entry model to have a run-up, and past -/// `deal.close_ms` for the exit to have a tail. An empty tape yields no fill. +/// `deal.close_ms` for the exit to have a tail ([`required_spans`] is the caller's gate). An +/// empty tape yields no fill. /// /// Args: /// deal: The report row and what the caller knows about its market. diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 7a9db7ee..da23f59d 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -11,6 +11,7 @@ use crate::feed::types::Side; /// The ignored run over a live data root. mod real_data; +mod required; fn tick(t_ms: i64, price: f64, side: Side) -> Tick { Tick { diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index facb95df..c40f102b 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -26,12 +26,9 @@ use crate::db::analytics::Query; use crate::db::order_traces::{TraceEntry, read_many}; use crate::db::tuner::strategy_values_at; use crate::feed::report_traces::ArchivedLineKind; -use crate::market::trade_replay::{Coverage, TickQuery, query_held}; +use crate::market::trade_replay::{Coverage, TickQuery, query_held, replay_window_ms}; use crate::symbol::{coin_match_key, coin_of_market}; -/// The tape must reach this far back before the buy for the corridor to have a run-up. -const RUN_UP_MS: i64 = 30_000; - /// The archived first point of an entry line and every point of an exit line. type ArchivedLines = (Option<(i64, f64)>, Option>); @@ -57,17 +54,18 @@ fn archived_lines(deal: &Deal) -> ArchivedLines { } } -/// The held prints for a deal under one `(exchange, market)` spelling, through the worker. -fn held_ticks(exchange_key: &str, market: &str, from_ms: i64, to_ms: i64) -> Vec { +/// The held prints of one market spelling inside the spans, through the worker, and its +/// coverage of them. +fn held_ticks(exchange_key: &str, market: &str, spans: &Coverage) -> (Vec, Coverage) { let (reply, rx) = mpsc::channel(); query_held(TickQuery { exchange_key: exchange_key.to_string(), market: market.to_string(), - spans: Coverage::one((from_ms, to_ms)), + spans: spans.clone(), reply, }); rx.recv_timeout(Duration::from_secs(10)) - .map(|answer| answer.ticks) + .map(|answer| (answer.ticks, answer.covered)) .unwrap_or_default() } @@ -135,25 +133,27 @@ fn real_data_reproduction() { continue; }; let coin_key = coin_match_key(&deal.coin); + // The same window and the same gate the axis applies (`load.rs::replay_row`): the + // worker's coverage must include `required_spans`, whatever the prints say. + let Some(window) = replay_window_ms(deal.buy_ms, deal.close_ms, margin_ms) else { + continue; + }; + let spans = window.focus_spans(); let mut ticks: Vec = Vec::new(); + let mut covered = Coverage::none(); for (exchange, market) in pairs .iter() .filter(|(_, m)| coin_match_key(coin_of_market(m)) == coin_key) { - ticks.extend(held_ticks( - exchange, - market, - deal.buy_ms - margin_ms, - deal.close_ms + margin_ms, - )); + let (held, held_covered) = held_ticks(exchange, market, &spans); + ticks.extend(held); + for &span in held_covered.spans() { + covered.add(span); + } } ticks.sort_by(|a, b| a.time_ms.total_cmp(&b.time_ms)); ticks.dedup_by(|a, b| a.time_ms == b.time_ms && a.price == b.price && a.qty == b.qty); - let first = ticks.first().map(|t| t.time_ms as i64); - let last = ticks.last().map(|t| t.time_ms as i64); - if first.is_none_or(|f| f > deal.buy_ms - RUN_UP_MS) - || last.is_none_or(|l| l < deal.close_ms) - { + if ticks.is_empty() || !covered.covers(&required_spans(&deal, &spans)) { continue; } with_tape += 1; diff --git a/crates/moon-core/src/db/tuner/ticks/tests/required.rs b/crates/moon-core/src/db/tuner/ticks/tests/required.rs new file mode 100644 index 00000000..e3befded --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/tests/required.rs @@ -0,0 +1,65 @@ +use super::super::{Deal, RUN_UP_MS, TAIL_MS, required_spans}; +use super::deal; +use crate::market::trade_replay::{Coverage, replay_window_ms}; + +const MINUTE_MS: i64 = 60_000; + +fn deal_at(buy_ms: i64, close_ms: i64) -> Deal { + let mut d = deal(); + d.buy_ms = buy_ms; + d.close_ms = close_ms; + d +} + +/// A short deal asks for the run-up through the tail and nothing more: a tape that stops short +/// of the fifteen-minute trail still counts, one that starts inside the run-up or ends inside +/// the tail does not. +#[test] +fn short_deal_requires_the_run_up_through_the_tail_only() { + let buy = 100 * MINUTE_MS; + let close = buy + 20_000; + let window = replay_window_ms(buy, close, 15 * MINUTE_MS).expect("window"); + let spans = window.focus_spans(); + let required = required_spans(&deal_at(buy, close), &spans); + assert_eq!(required.spans(), &[(buy - RUN_UP_MS, close + TAIL_MS)]); + let trail_cut = Coverage::one((buy - RUN_UP_MS, close + TAIL_MS)); + assert!( + trail_cut.covers(&required), + "the trail past the tail is optional" + ); + let late_start = Coverage::one((buy - RUN_UP_MS + 1, close + 15 * MINUTE_MS)); + assert!(!late_start.covers(&required), "the run-up is not"); + let ends_at_close = Coverage::one((buy - 15 * MINUTE_MS, close)); + assert!(!ends_at_close.covers(&required), "neither is the tail"); +} + +/// A long position's window asks only around its two ends, so the requirement is clipped to +/// them: the unwalked hours in the middle are not owed. A zero margin asks for the position +/// alone, and the requirement shrinks to it. +#[test] +fn long_position_and_zero_margin_require_only_what_the_window_asks() { + let buy = 100 * MINUTE_MS; + let close = buy + 8 * 60 * MINUTE_MS; + let margin = 15 * MINUTE_MS; + let window = replay_window_ms(buy, close, margin).expect("window"); + let spans = window.focus_spans(); + assert!(spans.is_split()); + let required = required_spans(&deal_at(buy, close), &spans); + let half = margin / 2; + assert_eq!( + required.spans(), + &[ + (buy - RUN_UP_MS, buy + half), + (close - half, close + TAIL_MS) + ] + ); + assert!(spans.covers(&required)); + let close = buy + 20_000; + let bare = replay_window_ms(buy, close, 0) + .expect("window") + .focus_spans(); + assert_eq!( + required_spans(&deal_at(buy, close), &bare).spans(), + &[(buy, close)] + ); +} diff --git a/crates/moon-core/src/diagnostics/filter.rs b/crates/moon-core/src/diagnostics/filter.rs index fbd732ec..56511355 100644 --- a/crates/moon-core/src/diagnostics/filter.rs +++ b/crates/moon-core/src/diagnostics/filter.rs @@ -32,8 +32,11 @@ use super::config::DiagCfg; /// Raised for `panels::chart` alone rather than the whole binary: that subtree is where the money /// paths log — the manual order, its refusals, the shot — and it is a handful of event-driven /// lines, whereas the binary at large has never had its volume at `info` measured even once. -pub const DEFAULT_BASE_FILTER: &str = - "warn,moonterminal::panels::chart=info,moon_gpui=info,moon_core=info"; +/// [`TICKS_AXIS_TARGET`] is the one other raise, for the same reason: one line per load of the +/// tuner's Entry/Exit axis and one per deal it asks the venue for, written so a batch that looks +/// stuck can be read instead of guessed. +pub const DEFAULT_BASE_FILTER: &str = "warn,moonterminal::panels::chart=info,\ + moonterminal::analytics::tuner::ticks=info,moon_gpui=info,moon_core=info"; /// Module prefix carrying balance-repair tracing (`feed::live` and its children). const BALANCES_TARGET: &str = "moon_core::feed::live"; @@ -139,3 +142,10 @@ pub fn filter_rejection(filter: &str) -> Option { #[cfg(test)] mod tests; + +/// Explicit `target:` of the tuner Entry/Exit axis' lines — its load and its per-deal tape +/// fetch — raised to `info` by [`DEFAULT_BASE_FILTER`]. Named here, beside the directive, so the +/// two cannot drift apart: the axis logs with this constant as its target rather than with its +/// own `module_path!()`, which would be muted again by the binary's `warn` baseline the day the +/// module moved. +pub const TICKS_AXIS_TARGET: &str = "moonterminal::analytics::tuner::ticks"; diff --git a/crates/moon-core/src/diagnostics/filter/tests.rs b/crates/moon-core/src/diagnostics/filter/tests.rs index c5a26c6d..2d77f33e 100644 --- a/crates/moon-core/src/diagnostics/filter/tests.rs +++ b/crates/moon-core/src/diagnostics/filter/tests.rs @@ -276,3 +276,14 @@ fn the_hotkey_area_raises_dispatch_tracing_and_nothing_else() { "off by default, like every other area" ); } + +/// The tuner's fetch line is written with `TICKS_AXIS_TARGET` as its explicit target, so the +/// base filter must raise exactly that target — a drift between the two mutes the line silently. +#[test] +fn the_ticks_fetch_target_is_raised_by_the_base_filter() { + let directive = format!("{TICKS_AXIS_TARGET}=info"); + assert!( + DEFAULT_BASE_FILTER.contains(&directive), + "{DEFAULT_BASE_FILTER:?} does not raise {directive:?}" + ); +} diff --git a/crates/moon-core/src/diagnostics/mod.rs b/crates/moon-core/src/diagnostics/mod.rs index bed7f292..c409d2b8 100644 --- a/crates/moon-core/src/diagnostics/mod.rs +++ b/crates/moon-core/src/diagnostics/mod.rs @@ -32,7 +32,9 @@ use std::sync::{Mutex, OnceLock, RwLock}; use std::time::Duration; pub use config::DiagCfg; -pub use filter::{CHART_INPUT_TARGET, DEFAULT_BASE_FILTER, HOTKEYS_TARGET, filter_string}; +pub use filter::{ + CHART_INPUT_TARGET, DEFAULT_BASE_FILTER, HOTKEYS_TARGET, TICKS_AXIS_TARGET, filter_string, +}; use crate::config::paths; diff --git a/crates/moon-core/src/market/trade_replay/gate.rs b/crates/moon-core/src/market/trade_replay/gate.rs index 30e69065..d6bb4fe5 100644 --- a/crates/moon-core/src/market/trade_replay/gate.rs +++ b/crates/moon-core/src/market/trade_replay/gate.rs @@ -215,6 +215,9 @@ impl ReplayGate { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let previous = claims.get(host).copied(); + if let Some(remaining) = refused_for(previous.as_ref(), now) { + return Err(remaining); + } claims.insert( host, Attempt { @@ -245,6 +248,26 @@ impl ReplayGate { refused_for(claims.get(host), now) } + /// Forget a host's refusal history after it answered successfully. + /// + /// The number a requester waits out before asking again — the same one [`Self::claim`] + /// would refuse with right now — read after a walk stopped on the gate, where a second + /// `claim` would take the permit the moment the wait ended. + /// + /// Args: + /// host: Stable host key, from the route. + /// now: Current instant. + /// + /// Returns: + /// Remaining seconds, or `None` when a permit may be taken now. + pub fn refused_for(&self, host: &'static str, now: Instant) -> Option { + let claims = self + .claims + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + refused_for(claims.get(host), now) + } + /// Forget a host's refusal history after it answered a request sent at `asked_at`, AFTER /// the refusal. /// diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index f07ec46a..e4ad2275 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -251,7 +251,7 @@ pub enum TradeReplayOutcome { /// Who is asking for the prints, which decides three things the requester cannot express in /// the window alone: which margin the walk spends its page budget on first, whether a short /// answer waits for the core's archive, and whether the remembered-answer ring is consulted. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ReplayIntent { /// A live trade window. The exit's trail is walked before the entry's lead — the part of the /// picture the eye lands on — and an answer short of the focus keeps polling the core's @@ -271,32 +271,11 @@ impl ReplayIntent { matches!(self, Self::Model) } - /// How far outside the position the trade tiles reach — see [`MODEL_PAD_MS`]. A chart's - /// trade tiles are the position alone. - pub(crate) fn trade_pad_ms(self) -> i64 { - match self { - Self::Chart => 0, - Self::Model => MODEL_PAD_MS, - } - } - /// Whether an answer short of the focus arms the bounded core-archive follow-up. pub(crate) fn awaits_core(self) -> bool { matches!(self, Self::Chart) } - /// Whether what the core's ring holds of the window is FILED into the tiles rather than - /// answered from. A model's requester reads the tiles, not the answer: an answer straight - /// from the ring — the candle stage's core-first, the tick stage's own read, the mid-walk - /// upgrade — reached nobody, since none of them files a tile and the close-time capture that - /// would have filed it never ran for a trade that closed while the terminal was down. - /// Filed, the ring's stretch is what the walk no longer asks the venue - /// for, and the held query finds it. A chart keeps the ring as an answer: its window shows - /// the series it is sent. - pub(crate) fn files_core(self) -> bool { - matches!(self, Self::Model) - } - /// Whether the remembered-answer ring is read and written for this request. A model's /// request does neither: the ring is keyed by the window alone, so a chart's answer that /// stopped on the page budget before its lead would be served to the model with no run-up, diff --git a/crates/moon-core/src/market/trade_replay/tests.rs b/crates/moon-core/src/market/trade_replay/tests.rs index c234d079..09d0dd84 100644 --- a/crates/moon-core/src/market/trade_replay/tests.rs +++ b/crates/moon-core/src/market/trade_replay/tests.rs @@ -1167,9 +1167,6 @@ fn tick_plan_of_a_long_position_tiles_the_entry_and_the_exit_only() { /// A model's request walks the entry's lead before the exit's trail on a forward route — the /// run-up is what its entry model reads — and the lead is still walked away from the trade, so /// every completed prefix stays one stretch. A backward route already walks the lead first. -/// The model's trade tile reaches [`MODEL_PAD_MS`] outside the position — the run-up and the -/// tail are walked under the trade budget, never as an optional margin; a chart's is the -/// position alone. #[test] fn tick_plan_of_a_model_request_walks_the_lead_before_the_trail() { use venue_caps::TradeRoute::*; @@ -1177,29 +1174,23 @@ fn tick_plan_of_a_model_request_walks_the_lead_before_the_trail() { let close_ms = open_ms + MINUTE_MS; let window = replay_window_ms(open_ms, close_ms, MARGIN_MS).expect("window"); let (focus_from, focus_to) = window.focus(); - let padded = (open_ms - MODEL_PAD_MS, close_ms + MODEL_PAD_MS); - let lead = (focus_from, padded.0 - 1); - let trail = (padded.1 + 1, focus_to); + let lead = (focus_from, open_ms - 1); + let trail = (close_ms + 1, focus_to); + let trade = (open_ms, close_ms); let forward = tick_plan(window, BinanceUsdMAggTrades, None, ReplayIntent::Model); - assert_eq!(forward.slices, vec![padded, lead, trail]); + assert_eq!(forward.slices, vec![trade, lead, trail]); assert_eq!((forward.trade_len, forward.focus_len), (1, 3)); - let trade = (open_ms, close_ms); let chart = tick_plan(window, BinanceUsdMAggTrades, None, ReplayIntent::Chart); assert_eq!( chart.slices, - vec![trade, (close_ms + 1, focus_to), (focus_from, open_ms - 1)], - "the chart keeps its order and its bare trade tile" + vec![trade, trail, lead], + "the chart keeps its order" ); let backward = tick_plan(window, OkxHistoryTrades, None, ReplayIntent::Model); assert_eq!( backward.slices, - vec![padded, lead, trail], - "a backward route walks the lead first for either intent, the model's tile padded" - ); - assert_eq!( tick_plan(window, OkxHistoryTrades, None, ReplayIntent::Chart).slices, - vec![trade, (focus_from, open_ms - 1), (close_ms + 1, focus_to)], - "and the chart's bare" + "a backward route walks the lead first for either intent" ); } diff --git a/crates/moon-core/src/market/trade_replay/worker.rs b/crates/moon-core/src/market/trade_replay/worker.rs index c61d1aa7..a84f3876 100644 --- a/crates/moon-core/src/market/trade_replay/worker.rs +++ b/crates/moon-core/src/market/trade_replay/worker.rs @@ -667,6 +667,33 @@ fn run(rx: &Receiver, back: Sender) { continue; }; match job { + Job::Candles(request) => { + // A window that closed while its request sat in the queue costs nothing at all: + // this is the cheapest of the three cancellation guards and the only one that + // prevents the work. + if request.cancel.load(Ordering::Relaxed) { + continue; + } + let mut served = serve_with_core(&agent, &gate, &cache, &request); + // No native wait either with the stage off: the wait is the core-archive half + // of the same stage, and it would poll the archive for a window that asked for + // the bars alone. + let native_wait = match request.ticks { + true => arm_native_wait(&request, &mut served, Instant::now()), + false => None, + }; + // The receiver is gone whenever the window closed mid-fetch. Normal, not an + // error — and exactly the signal that a queued tick stage would now answer no + // one, so it is never queued on a failed send. + let sent = request.reply.send(served.outcome).is_ok(); + if sent { + if let Some(stage) = served.tick_stage { + queue.push_back(Job::Ticks(request, stage)); + } else if let Some(wait) = native_wait { + native_waits.push((request, wait)); + } + } + } Job::Capture(request, spans, settle_pass) => { for &span in spans.spans() { capture_from_core(&request, span, tiles); @@ -823,7 +850,7 @@ fn run_lane(rx: &Receiver, shared: &Shared) { // The same holds when the walk was abandoned and the tile store served // the part of the focus it held: what it did not hold is still owed. remember_store( - cache, + &cache, request.intent, stage.key, Remembered::Ready { @@ -857,7 +884,7 @@ fn run_lane(rx: &Receiver, shared: &Shared) { // the fetch itself did not produce an answer, so a reopen must retry it. if status == TickStatus::NoTrades { remember_store( - cache, + &cache, request.intent, stage.key, Remembered::Ready { diff --git a/crates/moon-ui-gpui/src/analytics/bg.rs b/crates/moon-ui-gpui/src/analytics/bg.rs index 64c38e27..fb0dc25c 100644 --- a/crates/moon-ui-gpui/src/analytics/bg.rs +++ b/crates/moon-ui-gpui/src/analytics/bg.rs @@ -33,8 +33,6 @@ pub(super) enum ReadLane { Ticks, /// The Entry/Exit axis' replay stage. TicksReplay, - /// The Entry/Exit axis' per-row tape fetch, one row at a time. - TicksFetch, /// The Entry/Exit axis' variant columns, rescored after an edit. TicksVariants, /// The Entry/Exit axis' search. @@ -144,7 +142,6 @@ impl AnalyticsView { ReadLane::Time, ReadLane::Ticks, ReadLane::TicksReplay, - ReadLane::TicksFetch, ReadLane::TicksVariants, ReadLane::TicksSearch, ]); @@ -161,7 +158,6 @@ impl AnalyticsView { ReadLane::Time, ReadLane::Ticks, ReadLane::TicksReplay, - ReadLane::TicksFetch, ReadLane::TicksVariants, ReadLane::TicksSearch, ]); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index c232effe..95fc1ca9 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -1,56 +1,78 @@ -//! "Fetch the tape" — the rows whose window the terminal does not hold, asked from the venue -//! one at a time through the same replay request a trade window makes. +//! "Fetch the tape" — the view's side of the batch that [`job`] runs. //! -//! One at a time because the worker is one thread and the venues are rate-limited: a burst of -//! a hundred requests would queue a hundred tick walks behind every chart window's own. The -//! answers are read for their STATUS only (`NoRoute`, `OutOfRetention`, …) and shown in the -//! row's tooltip, so the button does not look broken on a venue without a public route; the -//! prints themselves land in the worker's tiles and on disk, and the row is then replayed -//! through the same held-data query the load uses. +//! The view resolves what a request needs while it still has the live source (the core's +//! exchange identity, the contract terms), hands the rows to the process-wide job, and listens: +//! a row that lands is folded into the table by id, whatever scope the table shows by then. The +//! job outlives the window; a reopened window attaches a fresh listener and reads the same +//! progress for its caption. -use std::sync::Arc; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::sync::mpsc; +use std::time::Duration; use gpui::*; use super::super::super::AnalyticsView; -use super::load::{ArchivedLines, replay_row}; use super::state::TapeStatus; -use crate::analytics::bg::ReadLane; -use moon_core::db::order_traces::read_many; -use moon_core::db::tuner::ticks::Deal; -use moon_core::market::trade_replay::worker::{self, TradeReplayRequest}; -use moon_core::market::trade_replay::{ - TickStatus, TradeReplayOutcome, margin_ms, replay_window_ms, -}; +use moon_core::market::trade_replay::{margin_ms, replay_window_ms}; + +pub(in crate::analytics::tuner) mod job; + +/// How long one blocking receive holds a background-pool thread before checking back. +const LISTEN_SLICE: Duration = Duration::from_secs(2); impl AnalyticsView { - /// Queue every fetchable row and start on the first. + /// Queue every fetchable row of the table with the job, oldest first. pub(in crate::analytics::tuner) fn ticks_fetch_missing(&mut self, cx: &mut Context) { - if self.ticks.fetch.is_active() { + // Until the tape stage has folded, every row reads "missing": queuing them would ask the + // venue for tape the tiles already hold. + if job::progress().active || self.ticks.tape_reading { return; } let Some(data) = self.ticks.data.data() else { return; }; - let mut pending: Vec = data.fetchable().map(|r| r.deal.report_uid).collect(); - if pending.is_empty() { - return; + // The same addressing a trade window resolves: the live source for the exchange + // identity, the core's contract terms for how the prints are valued. + let backend = self.backend.read(cx); + let source = backend.session.market_source(); + let mut rows: Vec = data + .fetchable() + .filter_map(|row| { + let address = row.address.clone()?; + let window = replay_window_ms(row.deal.buy_ms, row.deal.close_ms, margin_ms())?; + let replay_address = source.replay_address(address.core_uid).ok()?; + let terms = source.market_contract_terms(address.core_uid, &address.market); + let tick_value = moon_core::market::trade_replay::venue_caps::tick_value( + replay_address.venue, + terms.as_ref().map(|(quote, size)| (quote.as_str(), *size)), + ); + Some(job::QueuedRow::new( + row.deal.clone(), + address, + replay_address, + tick_value, + window, + )) + }) + .collect(); + // Oldest first, so the ones nearest the venues' retention edge go before it moves; the + // job pops from the end. + rows.reverse(); + let defaults = self.filter_defaults(cx); + if job::start(rows, defaults) { + self.attach_fetch_listener(cx); } - // Oldest first, so the ones nearest the venues' retention edge go before it moves. - pending.reverse(); - self.ticks.fetch.total = pending.len(); - self.ticks.fetch.done = 0; - self.ticks.fetch.pending = pending; - self.ticks_fetch_next(cx); + cx.notify(); } - /// Abandon the queue; the request in flight finishes on its own and is dropped on arrival. + /// Abandon the batch; the request in flight is cancelled by the job. pub(in crate::analytics::tuner) fn ticks_fetch_stop(&mut self, cx: &mut Context) { - let in_flight = self.ticks.fetch.in_flight; - self.ticks.fetch.clear(); - if let Some(uid) = in_flight { + // Read before the stop: the job clears its in-flight row on its own thread the moment + // the cancelled walk returns, and no row event follows a cancellation. + let in_flight = job::progress().in_flight; + job::stop(); + if let Some((uid, _)) = in_flight { self.ticks.update_row(uid, |row| { if row.tape == TapeStatus::Fetching { row.tape = TapeStatus::Missing; @@ -60,140 +82,104 @@ impl AnalyticsView { cx.notify(); } - /// Ask for the next queued row, if any. - fn ticks_fetch_next(&mut self, cx: &mut Context) { - let Some(uid) = self.ticks.fetch.pending.pop() else { - self.ticks.fetch.in_flight = None; - cx.notify(); - return; - }; - let Some((deal, address)) = self.ticks.data.data().and_then(|d| { - d.rows - .iter() - .find(|r| r.deal.report_uid == uid) - .and_then(|r| Some((r.deal.clone(), r.address.clone()?))) - }) else { - self.ticks_fetch_next(cx); + /// Listen to the job from this view, once per batch: what lands is folded into the table by + /// id. The task ends with the batch, or with the view; a reopened window, or the next batch, + /// attaches its own. + pub(in crate::analytics::tuner) fn attach_fetch_listener(&mut self, cx: &mut Context) { + if self.ticks.fetch_listening.load(Ordering::Relaxed) { return; - }; - let Some(window) = replay_window_ms(deal.buy_ms, deal.close_ms, margin_ms()) else { - self.ticks_fetch_next(cx); - return; - }; - // The same addressing a trade window resolves: the live source for the exchange - // identity, the core's contract terms for how the prints are valued. - let resolved = { - let backend = self.backend.read(cx); - let source = backend.session.market_source(); - source - .replay_address(address.core_uid) - .ok() - .map(|replay_address| { - let terms = source.market_contract_terms(address.core_uid, &address.market); - let tick_value = moon_core::market::trade_replay::venue_caps::tick_value( - replay_address.venue, - terms.as_ref().map(|(quote, size)| (quote.as_str(), *size)), - ); - (replay_address, tick_value) - }) - }; - let Some((replay_address, tick_value)) = resolved else { - // The core went away since the load resolved the row: not a fetch failure, an - // address the row no longer has. - self.ticks - .update_row(uid, |row| row.tape = TapeStatus::NoAddress); - self.ticks_fetch_next(cx); - return; - }; - self.ticks.fetch.in_flight = Some(uid); - let seq = self.ticks.fetch.seq; - self.ticks - .update_row(uid, |row| row.tape = TapeStatus::Fetching); - let (reply, rx) = mpsc::channel(); - worker::request(TradeReplayRequest { - address: replay_address, - market: address.market.clone(), - window, - identity: fetch_identity(uid), - tick_value, - ticks: true, - cancel: Arc::new(AtomicBool::new(false)), - reply, - }); - let defaults = self.filter_defaults(cx); - self.spawn_latest_db( - &[ReadLane::TicksFetch], - false, - cx, - move || { - // The worker streams the candle stage, then the tick stage, then drops the - // sender: the last outcome before the drop is the tick stage's word. - let mut status = TickStatus::Failed; - while let Ok(outcome) = rx.recv() { - status = match outcome { - TradeReplayOutcome::Ready(series) => series.tick_status, - TradeReplayOutcome::Empty(_) | TradeReplayOutcome::Failed(_) => { - TickStatus::Failed - } - }; + } + let listening = self.ticks.fetch_listening.clone(); + listening.store(true, Ordering::Relaxed); + let rx: mpsc::Receiver = job::attach(); + self.ticks.fetch_task = Some(cx.spawn(async move |this, cx| { + let executor = cx.update(|cx| cx.background_executor().clone()); + // The receiver travels into the background task and back out each iteration + // instead of living behind a lock across the await, so the blocking `recv` never + // holds anything this foreground task still needs between messages. The wait is + // bounded: a batch asleep on a venue's backoff sends nothing for minutes, and a + // window closed meanwhile must give the pool its thread back, not hold it until + // the next event. + let mut rx = rx; + loop { + let (returned_rx, received) = executor + .spawn(async move { + let received = rx.recv_timeout(LISTEN_SLICE); + (rx, received) + }) + .await; + rx = returned_rx; + let mut events = Vec::new(); + match received { + Ok(event) => events.push(event), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, } - let lines = archived_lines_of(&deal); - let mut row = super::state::DealRow { - deal, - tape: TapeStatus::Missing, - verdict: None, - address: Some(address), - ticks: None, - entry_start: None, - }; - replay_row(&mut row, &defaults, lines); - if row.tape == TapeStatus::Missing { - row.tape = match status { - TickStatus::Served | TickStatus::Pending | TickStatus::Streaming => { - TapeStatus::Missing + // Everything else already queued goes in the same hop: the last row's answer + // lands right before the batch goes idle, and a check on "idle" must not come + // before it has been read. + while let Ok(event) = rx.try_recv() { + events.push(event); + } + let applied = cx.update(|cx| { + this.update(cx, |this, cx| { + for event in events { + this.apply_fetch_event(event, cx); } - refused => TapeStatus::Refused(refused), - }; + }) + }); + // The view is gone; the job goes on without a listener. + if applied.is_err() { + break; } - row - }, - move |this, row, cx| { - if this.ticks.fetch.seq != seq { - return; + // The batch is over and the channel was drained: nothing more will come until + // the next start, which attaches afresh. + if !job::progress().active { + break; } - let uid = row.deal.report_uid; - this.ticks.update_row(uid, |slot| { - slot.tape = row.tape; - slot.verdict = row.verdict; - slot.deal.tick = row.deal.tick; - slot.ticks = row.ticks; - slot.entry_start = row.entry_start; + } + listening.store(false, Ordering::Relaxed); + })); + } + + /// Fold one job event into the table. + fn apply_fetch_event(&mut self, event: job::JobEvent, cx: &mut Context) { + match event { + // A start still queued when the batch was stopped marks nothing: no answer would + // follow to unmark it. + job::JobEvent::Started(uid) if job::progress().active => { + self.ticks.update_row(uid, |row| { + if row.tape == TapeStatus::Missing { + row.tape = TapeStatus::Fetching; + } + }); + } + job::JobEvent::Started(_) => {} + job::JobEvent::Row(answer) => { + let uid = answer.deal.report_uid; + self.ticks.update_row(uid, |slot| { + slot.tape = answer.tape; + slot.verdict = answer.verdict; + slot.deal.tick = answer.deal.tick; + slot.ticks = answer.ticks; + slot.entry_start = answer.entry_start; }); - this.ticks.fetch.done += 1; // A row joined the replayable set: the variant columns are due a rescore. - this.arm_ticks_variants(cx); - this.ticks_fetch_next(cx); - }, - ); + self.arm_ticks_variants(cx); + } + job::JobEvent::Progress => {} + } + cx.notify(); } -} -/// A replay identity for a fetch, distinct from every chart window's: the row's own id, which -/// no window uses as a series discriminator. -fn fetch_identity(report_uid: i64) -> u64 { - // FNV-1a over the id, salted so a window's `identity` (an entity number) cannot collide. - let mut hash: u64 = 0xcbf2_9ce4_8422_2325 ^ 0x7469_636b_7300_0000; - for byte in report_uid.to_le_bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + /// Mark the row the job is out for, after the table was rebuilt. + pub(in crate::analytics::tuner) fn mark_fetch_in_flight(&mut self) { + if let Some((uid, _)) = job::progress().in_flight { + self.ticks.update_row(uid, |row| { + if row.tape == TapeStatus::Missing { + row.tape = TapeStatus::Fetching; + } + }); + } } - hash | 1 -} - -/// The archived lines of one deal. -fn archived_lines_of(deal: &Deal) -> ArchivedLines { - read_many(deal.core_uid, &[deal.report_uid]) - .ok() - .and_then(|entries| entries.get(&deal.report_uid).map(ArchivedLines::of)) - .unwrap_or_default() } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs new file mode 100644 index 00000000..4d743d6e --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -0,0 +1,459 @@ +//! The tape fetch as a job of the PROCESS, not of the window. +//! +//! The Analytics window is created and dropped with its view; a batch that lived inside the view +//! died with it, and a batch asleep on a venue's backoff had nothing in flight to keep a scope +//! reload from wiping it. Here the queue, the walk order, the per-venue backoff waits and the +//! progress live on one background thread that outlives every window; a view only hands it rows +//! (resolved while the view still had the live source), listens for what lands, and reads the +//! progress for its caption. Closing the window drops the listener, nothing else; reopening it +//! attaches a new one and reads the same progress. +//! +//! One row at a time, because the replay worker is one thread and the venues are rate-limited. +//! A venue that refuses (the gate's backoff, or its own error mid-walk) puts its rows aside for +//! the gate's own number of seconds while the rows of other venues go on; the refused row goes +//! back first once the wait is out, and a row the venue itself refused is asked once more before +//! it counts as final. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex, OnceLock, mpsc}; +use std::time::{Duration, Instant}; + +use super::super::load::{ArchivedLines, replay_row}; +use super::super::state::{DealRow, RowAddress, TapeStatus}; +use moon_core::db::order_traces::read_many; +use moon_core::db::tuner::ticks::Deal; +use moon_core::market::ReplayAddress; +use moon_core::market::trade_replay::venue_caps::TickValue; +use moon_core::market::trade_replay::worker::{self, TradeReplayRequest}; +use moon_core::market::trade_replay::{ + ReplayIntent, ReplayWindow, TickStatus, TradeReplayEmpty, TradeReplayFailure, + TradeReplayOutcome, +}; + +/// The wait after a venue's own refusal mid-walk, when the gate names no number: the gate's own +/// floor, so the second ask lands after the backoff it will have recorded. +const VENUE_REFUSAL_WAIT: Duration = Duration::from_secs(30); + +/// One deal as the job asks for it: everything the request needs, resolved by the view while it +/// still had the live source for the exchange identity and the contract terms. +pub(in crate::analytics::tuner) struct QueuedRow { + pub(in crate::analytics::tuner) deal: Deal, + pub(in crate::analytics::tuner) address: Arc, + pub(in crate::analytics::tuner) replay_address: ReplayAddress, + pub(in crate::analytics::tuner) tick_value: TickValue, + pub(in crate::analytics::tuner) window: ReplayWindow, + /// Whether the venue itself already refused this row once; the second refusal is final. + retried: bool, +} + +impl QueuedRow { + pub(in crate::analytics::tuner) fn new( + deal: Deal, + address: Arc, + replay_address: ReplayAddress, + tick_value: TickValue, + window: ReplayWindow, + ) -> Self { + Self { + deal, + address, + replay_address, + tick_value, + window, + retried: false, + } + } +} + +/// What the job tells whoever listens. +pub(in crate::analytics::tuner) enum JobEvent { + /// A request went out for this row. + Started(i64), + /// A row's answer: what the row became, tape and verdict included. + Row(Box), + /// Progress moved without a row answer — a deferral, a resume, the end of the batch. + Progress, +} + +/// The job as a caption reads it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(in crate::analytics::tuner) struct Progress { + /// Whether a batch is running — in flight, queued, or asleep on a venue's wait. + pub(in crate::analytics::tuner) active: bool, + /// Rows answered since the batch started, and the batch's size. + pub(in crate::analytics::tuner) done: usize, + pub(in crate::analytics::tuner) total: usize, + /// The row a request is out for: its id and its market. + pub(in crate::analytics::tuner) in_flight: Option<(i64, String)>, +} + +/// A row set aside until its venue's wait is out. +struct Deferred { + row: QueuedRow, + due: Instant, +} + +#[derive(Default)] +struct State { + /// Rows still to ask for; popped from the end, so the caller orders them newest-first. + pending: Vec, + deferred: Vec, + in_flight: Option<(i64, String)>, + /// The in-flight request's cancel flag, raised by a stop so the walk ends at once. + in_flight_cancel: Option>, + done: usize, + total: usize, + /// The strategy-field defaults the model runs with, captured when the batch started. + defaults: HashMap, + /// Raised by a stop; the thread empties the queue and idles. + stop: bool, + /// The window listening, if any; a dead receiver is the window gone, and is dropped. + listener: Option>, + /// When each row of the running batch was answered, for a reload that read the row before + /// the answer landed to re-read it — see [`finished_after`]. + finished: Vec<(i64, Instant)>, +} + +impl State { + fn active(&self) -> bool { + self.in_flight.is_some() || !self.pending.is_empty() || !self.deferred.is_empty() + } + + fn notify(&mut self, event: JobEvent) { + if self + .listener + .as_ref() + .is_some_and(|listener| listener.send(event).is_err()) + { + self.listener = None; + } + } + + /// Set aside `row` and every pending row on the same exchange key until `wait` is out; + /// rows on other venues stay in the queue and keep going. The refused row rejoins first. + fn defer(&mut self, row: QueuedRow, wait: Duration) { + let key = row.address.exchange_key.clone(); + let due = Instant::now() + wait; + let (same, other) = split_by_key(std::mem::take(&mut self.pending), &key, |r| { + &r.address.exchange_key + }); + self.pending = other; + // `pending` pops from the end: the refused row goes in last, so it comes out first. + for row in same.into_iter().chain(std::iter::once(row)) { + self.deferred.push(Deferred { row, due }); + } + } + + /// Return every row whose wait is out to the queue, in the order it was set aside. + fn resume_due(&mut self, now: Instant) -> bool { + let (due, later): (Vec, Vec) = + self.deferred.drain(..).partition(|d| d.due <= now); + self.deferred = later; + let any = !due.is_empty(); + self.pending.extend(due.into_iter().map(|d| d.row)); + any + } +} + +struct Job { + state: Mutex, + wake: Condvar, +} + +static JOB: OnceLock = OnceLock::new(); + +fn job() -> &'static Job { + JOB.get_or_init(|| Job { + state: Mutex::new(State::default()), + wake: Condvar::new(), + }) +} + +fn lock(job: &Job) -> std::sync::MutexGuard<'_, State> { + job.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// The thread, started with the first batch and never stopped. +static THREAD: OnceLock<()> = OnceLock::new(); + +fn ensure_thread() { + THREAD.get_or_init(|| { + std::thread::Builder::new() + .name("tuner-ticks-fetch".into()) + .spawn(|| run(job())) + .expect("spawn the tuner fetch thread"); + }); +} + +/// Start a batch, unless one is running. `rows` is newest-first: the job pops from the end, +/// so the oldest — nearest the venues' retention edge — goes first. +/// +/// Returns: +/// Whether the batch was taken. +pub(in crate::analytics::tuner) fn start( + rows: Vec, + defaults: HashMap, +) -> bool { + if rows.is_empty() { + return false; + } + ensure_thread(); + let job = job(); + let mut st = lock(job); + if st.active() { + return false; + } + st.total = rows.len(); + st.done = 0; + st.pending = rows; + st.deferred.clear(); + st.finished.clear(); + st.defaults = defaults; + st.stop = false; + st.notify(JobEvent::Progress); + drop(st); + job.wake.notify_all(); + true +} + +/// Abandon the batch: the queue empties, the request in flight is cancelled and its answer is +/// dropped. +pub(in crate::analytics::tuner) fn stop() { + let job = job(); + let mut st = lock(job); + st.stop = true; + st.pending.clear(); + st.deferred.clear(); + if let Some(cancel) = &st.in_flight_cancel { + cancel.store(true, Ordering::Relaxed); + } + st.notify(JobEvent::Progress); + drop(st); + job.wake.notify_all(); +} + +/// Listen to the job from now on; the previous listener, if any, is replaced. +pub(in crate::analytics::tuner) fn attach() -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + lock(job()).listener = Some(tx); + rx +} + +/// The job as the caption reads it. +pub(in crate::analytics::tuner) fn progress() -> Progress { + let st = lock(job()); + Progress { + active: st.active(), + done: st.done, + total: st.total, + in_flight: st.in_flight.clone(), + } +} + +/// Rows of the running batch answered after `since` — what a reload that started reading at +/// `since` may have read before the answer landed, and must read again. +pub(in crate::analytics::tuner) fn finished_after(since: Instant) -> Vec { + lock(job()) + .finished + .iter() + .filter(|(_, at)| *at >= since) + .map(|(uid, _)| *uid) + .collect() +} + +/// Split a queue into the rows on `key` and the rest, both in their queue order. +fn split_by_key(rows: Vec, key: &str, key_of: impl Fn(&T) -> &str) -> (Vec, Vec) { + rows.into_iter().partition(|row| key_of(row) == key) +} + +/// How long the batch waits before asking for the same row again, when it should: the gate +/// refused the host and the row is still uncovered. A row the tiles covered anyway needs no +/// second ask, and every other status is the row's final word. +pub(super) fn retry_wait(status: TickStatus, tape: TapeStatus) -> Option { + match (status, tape) { + (TickStatus::RateLimited { retry_in_s }, TapeStatus::Missing) => Some(retry_in_s), + _ => None, + } +} + +/// The thread: one row at a time, waits included. +fn run(job: &Job) { + loop { + let (row, defaults) = { + let mut st = lock(job); + loop { + if st.stop { + st.stop = false; + st.pending.clear(); + st.deferred.clear(); + } + let now = Instant::now(); + if st.resume_due(now) { + st.notify(JobEvent::Progress); + } + if let Some(row) = st.pending.pop() { + break (row, st.defaults.clone()); + } + if st.deferred.is_empty() { + // The batch is over, or none was ever started: sleep until a start. + st.notify(JobEvent::Progress); + st = job + .wake + .wait(st) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } else { + let earliest = st.deferred.iter().map(|d| d.due).min().unwrap_or(now); + let wait = earliest.saturating_duration_since(now); + st = job + .wake + .wait_timeout(st, wait) + .unwrap_or_else(std::sync::PoisonError::into_inner) + .0; + } + } + }; + serve_one(job, row, &defaults); + } +} + +/// Ask for one row, wait for the worker, replay the row off the tiles, and file the answer. +fn serve_one(job: &Job, mut row: QueuedRow, defaults: &HashMap) { + let uid = row.deal.report_uid; + let cancel = Arc::new(AtomicBool::new(false)); + let (reply, rx) = mpsc::channel(); + let progress = { + let mut st = lock(job); + st.in_flight = Some((uid, row.address.market.clone())); + st.in_flight_cancel = Some(cancel.clone()); + st.notify(JobEvent::Started(uid)); + (st.done + 1, st.total) + }; + let started = Instant::now(); + worker::request(TradeReplayRequest { + address: row.replay_address.clone(), + market: row.address.market.clone(), + window: row.window, + identity: fetch_identity(uid), + tick_value: row.tick_value, + ticks: true, + intent: ReplayIntent::Model, + cancel: cancel.clone(), + reply, + }); + // The worker streams the candle stage, then the tick stage, then drops the sender: the + // last outcome before the drop is the tick stage's word. A model's request arms no archive + // follow-up, so the drop comes right after the stage. + let mut status = TickStatus::Failed; + let mut outcomes = 0usize; + while let Ok(outcome) = rx.recv() { + outcomes += 1; + status = match outcome { + TradeReplayOutcome::Ready(series) => series.tick_status, + // The candle stage refused by the gate: the same wait as a refused walk. + TradeReplayOutcome::Failed(TradeReplayFailure::RateLimited { retry_in_s }) => { + TickStatus::RateLimited { retry_in_s } + } + // A venue this build has no route to was never asked, and says so; any other + // empty — no bars in the window at all — is a final word, not a refusal to ask + // again for. + TradeReplayOutcome::Empty(TradeReplayEmpty::NoEndpoint { .. }) => TickStatus::NoRoute, + TradeReplayOutcome::Empty(_) => TickStatus::NoTrades, + TradeReplayOutcome::Failed(_) => TickStatus::Failed, + }; + } + let answered = started.elapsed(); + if cancel.load(Ordering::Relaxed) { + // Stopped mid-walk: nothing to file, the queue is already empty. + let mut st = lock(job); + st.in_flight = None; + st.in_flight_cancel = None; + st.notify(JobEvent::Progress); + return; + } + let lines = archived_lines_of(&row.deal); + let mut answer = DealRow { + deal: row.deal.clone(), + tape: TapeStatus::Missing, + verdict: None, + address: Some(row.address.clone()), + ticks: None, + entry_start: None, + }; + replay_row(&mut answer, defaults, lines); + let mut wait = retry_wait(status, answer.tape).map(|s| Duration::from_secs(u64::from(s))); + // The venue itself refused mid-walk — the row that put its host into the backoff. Once + // more, after the wait the gate will name for the rows behind it; the second time is final. + if wait.is_none() + && answer.tape == TapeStatus::Missing + && status == TickStatus::Failed + && !row.retried + { + row.retried = true; + wait = Some(VENUE_REFUSAL_WAIT); + } + if answer.tape == TapeStatus::Missing && wait.is_none() { + answer.tape = match status { + TickStatus::Served | TickStatus::Pending | TickStatus::Streaming => TapeStatus::Missing, + refused => TapeStatus::Refused(refused), + }; + } + // One line per row, so a batch that looks stuck can be read instead of guessed: what the + // worker answered, how long it took, and what the row became. The binary logs at `warn` by + // default; this target is the one the base filter raises for exactly these lines. + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks fetch {}/{} {} uid={} window={}..{}: {status:?} after {outcomes} outcome(s) in {} ms, replayed in {} ms -> {}", + progress.0, + progress.1, + row.address.market, + uid, + row.window.from_ms, + row.window.to_ms, + answered.as_millis(), + started.elapsed().saturating_sub(answered).as_millis(), + match wait { + Some(wait) => format!("retry in {} s", wait.as_secs()), + None => format!("{:?}", answer.tape), + } + ); + let mut st = lock(job); + st.in_flight = None; + st.in_flight_cancel = None; + match wait { + Some(wait) if !st.stop => { + st.defer(row, wait); + st.notify(JobEvent::Row(Box::new(answer))); + } + _ => { + st.done += 1; + st.finished.push((uid, Instant::now())); + st.notify(JobEvent::Row(Box::new(answer))); + } + } +} + +/// A replay identity for a fetch, distinct from every chart window's: the row's own id, which +/// no window uses as a series discriminator. +fn fetch_identity(report_uid: i64) -> u64 { + // FNV-1a over the id, salted so a window's `identity` (an entity number) cannot collide. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325 ^ 0x7469_636b_7300_0000; + for byte in report_uid.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash | 1 +} + +/// The archived lines of one deal. +fn archived_lines_of(deal: &Deal) -> ArchivedLines { + read_many(deal.core_uid, &[deal.report_uid]) + .ok() + .and_then(|entries| entries.get(&deal.report_uid).map(ArchivedLines::of)) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs new file mode 100644 index 00000000..c6d532db --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs @@ -0,0 +1,37 @@ +// Explicit imports, never `use super::*`: the crate's views import `gpui::*`, whose own +// `test` shadows the built-in attribute and makes `#[test]` expand recursively. +use super::super::super::state::TapeStatus; +use super::{retry_wait, split_by_key}; +use moon_core::market::trade_replay::TickStatus; + +/// Only a gate refusal on a still-uncovered row is asked again, with the gate's own number; +/// a row the tiles covered anyway, and every other status, is final here (a venue's own +/// refusal gets its one retry in the thread, not in this rule). +#[test] +fn only_a_refused_uncovered_row_waits_the_gate_out() { + let refused = TickStatus::RateLimited { retry_in_s: 27 }; + assert_eq!(retry_wait(refused, TapeStatus::Missing), Some(27)); + assert_eq!(retry_wait(refused, TapeStatus::Covered), None); + assert_eq!(retry_wait(TickStatus::Failed, TapeStatus::Missing), None); + assert_eq!(retry_wait(TickStatus::NoRoute, TapeStatus::Missing), None); + assert_eq!(retry_wait(TickStatus::Served, TapeStatus::Missing), None); +} + +/// A deferral takes every queued row of the refused venue, in queue order, and leaves the +/// other venues' rows in theirs. +#[test] +fn a_deferral_takes_the_venues_rows_and_keeps_the_rest_in_order() { + let rows = vec![ + (1, "binance"), + (2, "gate"), + (3, "binance"), + (4, "okx"), + (5, "gate"), + ]; + let (same, other) = split_by_key(rows, "gate", |r| r.1); + assert_eq!(same, vec![(2, "gate"), (5, "gate")]); + assert_eq!(other, vec![(1, "binance"), (3, "binance"), (4, "okx")]); + let (none, all) = split_by_key(vec![(1, "binance")], "", |r| r.1); + assert!(none.is_empty(), "a row of no venue never joins a wait"); + assert_eq!(all, vec![(1, "binance")]); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index d7657df9..3a8fe63e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -3,9 +3,12 @@ //! Stage A reads the scope's deals, the whole-scope "Fact" KPI (the same SQL every axis' //! "Fact" comes from) and the grid's "now" values off the database. Its completion resolves, //! on the UI thread, where each deal's prints live — the core's exchange key and the coin's -//! market, which only the live market source knows — and starts stage B, which asks the -//! replay worker for the held tape of every deal, reads the archived entry line, and runs the -//! model on the parameters as of the buy. The axis' `LoadState` stays "loading" across both. +//! market, which only the live market source knows — and publishes the rows at once, without +//! their tape (stage B). Stage C then asks the replay worker for the held tape of every row in +//! ONE batch of queries, reads the archived entry lines, runs the model on the parameters as +//! of the buy, and folds the answers into the published rows. In one batch, because the worker +//! is one thread that serves held queries only between its walks: asked one at a time, a +//! thousand rows would each wait for a walk of the fetch batch that may be running. //! //! This file only ever WRITES `TicksState`; the rendering only reads it. @@ -23,7 +26,7 @@ use crate::analytics::refresh::{CatchUpOutcome, report_result_is_stale}; use moon_core::db::ReadFail; use moon_core::db::order_traces::{TraceEntry, read_many}; use moon_core::db::tuner::ticks::{ - Deal, DealsRead, EntryParams, entry_model_for, infer_tick, params, verify, + Deal, DealsRead, EntryParams, entry_model_for, infer_tick, params, required_spans, verify, }; use moon_core::db::tuner::{VarStats, Variant, strategy_current_values, strategy_values_at}; use moon_core::feed::report_traces::ArchivedLineKind; @@ -32,12 +35,11 @@ use moon_core::market::trade_replay::{ Coverage, TickQuery, margin_ms, query_held, replay_window_ms, }; -/// How long stage B waits for the worker's answer on one deal. The worker serves a held -/// query right after candle jobs, so an answer past this means the worker is gone. -const HELD_ANSWER_WAIT: Duration = Duration::from_secs(10); - -/// The tape must reach this far back before the buy for the corridor to have a run-up. -const RUN_UP_MS: i64 = 30_000; +/// How long a held query waits for the worker's answer. The worker serves a held query +/// between its jobs, and a walk of the fetch batch can hold it for up to its trade deadline +/// plus a candle stage, with chart windows' own stages queued ahead; past this the rows still +/// unanswered fold as missing, and the log says how many. +const HELD_ANSWER_WAIT: Duration = Duration::from_secs(240); /// What stage A brings back. type StageA = ( @@ -73,12 +75,10 @@ impl AnalyticsView { self.latest_reads.cancel(&[ ReadLane::Ticks, ReadLane::TicksReplay, - ReadLane::TicksFetch, ReadLane::TicksVariants, ReadLane::TicksSearch, ]); self.ticks.seq = self.ticks.seq.wrapping_add(1); - self.ticks.fetch.clear(); // A search over the previous deal set answers nothing about the new one; the lane // cancel above does not reach its handle, only this does. self.ticks.stop_search(); @@ -95,6 +95,26 @@ impl AnalyticsView { cx, move || { let deals = moon_core::db::tuner::ticks::read_deals(&q); + // One line per load, so "no deals" can be read against the scope that was + // actually asked — period, strategies — instead of guessed from the panel. + match &deals { + Ok(read) => log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks load: {} deal(s) with ms stamps, {} without, period {}..{}, {} strategy target(s)", + read.deals.len(), + read.without_ms, + q.from, + q.to, + q.strategies.len() + ), + Err(error) => log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks load failed: {error:?}, period {}..{}, {} strategy target(s)", + q.from, + q.to, + q.strategies.len() + ), + } let fact = moon_core::db::tuner::variant_stats(&q, &[Variant::default()]); let now = now_values(&targets, &keys); (deals, fact, now) @@ -177,9 +197,7 @@ impl AnalyticsView { out } - /// Stage B: the held tape of every deal, the archived entry line, the model on the - /// parameters as of the buy. Off the UI thread; the worker's answers are waited for one - /// at a time. + /// Stage B: the rows, published at once without their tape; stage C follows. #[allow(clippy::too_many_arguments)] fn start_replay_stage( &mut self, @@ -192,13 +210,12 @@ impl AnalyticsView { addresses: HashMap<(u64, String), Option>>, cx: &mut Context, ) { - let defaults = self.filter_defaults(cx); self.spawn_latest_db( &[ReadLane::TicksReplay], false, cx, move || { - let mut rows: Vec = read + let rows: Vec = read .deals .into_iter() .map(|deal| { @@ -220,16 +237,6 @@ impl AnalyticsView { } }) .collect(); - let mut traces = archived_lines(&rows); - for row in &mut rows { - // Each row waits on the worker; a scope change cancels this lane, and the - // wait is not a statement the progress handler could interrupt. - if moon_core::db::current_is_cancelled() { - break; - } - let lines = traces.remove(&row.deal.report_uid).unwrap_or_default(); - replay_row(row, &defaults, lines); - } let mut kinds: Vec = rows.iter().map(|r| r.deal.kind.clone()).collect(); kinds.sort(); kinds.dedup(); @@ -253,8 +260,12 @@ impl AnalyticsView { this.ticks.dirty = report_result_is_stale(report_req, this.current_report_generation(), false); this.ticks.publish(Ok(data), false); - // The replayable set may have changed under the variant columns: rescore them. - this.arm_ticks_variants(cx); + // The fetch job runs on across reloads and windows: a window that finds a batch + // running listens to it from here on. + if super::fetch::job::progress().active { + this.attach_fetch_listener(cx); + } + this.start_tape_stage(req, cx); if after_report { this.settle_report_refresh_retry(false, cx); } @@ -262,6 +273,146 @@ impl AnalyticsView { }, ); } + + /// Stage C: the held tape of every published row, asked from the worker in one batch, the + /// archived entry lines, and the model on the parameters as of the buy — folded into the + /// rows when all of it is in. + fn start_tape_stage(&mut self, req: u64, cx: &mut Context) { + let Some(data) = self.ticks.data.data() else { + return; + }; + let targets: Vec<(Deal, Arc)> = data + .rows + .iter() + .filter_map(|r| Some((r.deal.clone(), r.address.clone()?))) + .collect(); + if targets.is_empty() { + return; + } + let defaults = self.filter_defaults(cx); + self.ticks.tape_reading = true; + // The fetch job may answer rows while this stage reads them; the ones it answered after + // this instant are read again at the end, or the stage would fold the tape it read + // BEFORE the answer over the answer. + let reading_since = std::time::Instant::now(); + self.spawn_latest_db( + &[ReadLane::TicksReplay], + false, + cx, + move || { + let mut tapes = held_tapes(&targets); + let mut rows: Vec = targets + .into_iter() + .map(|(deal, address)| DealRow { + deal, + tape: TapeStatus::Missing, + verdict: None, + address: Some(address), + ticks: None, + entry_start: None, + }) + .collect(); + let mut traces = archived_lines(&rows); + for row in &mut rows { + let lines = traces.remove(&row.deal.report_uid).unwrap_or_default(); + let tape = tapes.remove(&row.deal.report_uid); + replay_row_with(row, &defaults, lines, tape); + } + // Rows the job answered while the batch was read: read again, each behind + // whatever walk is running. A row the job answers during THIS loop is kept + // covered by the fold (`update_rows`), not re-read once more. + let late = super::fetch::job::finished_after(reading_since); + if !late.is_empty() { + let traces = archived_lines(&rows); + for row in rows + .iter_mut() + .filter(|r| late.contains(&r.deal.report_uid)) + { + if moon_core::db::current_is_cancelled() { + break; + } + let lines = traces + .get(&row.deal.report_uid) + .cloned() + .unwrap_or_default(); + replay_row(row, &defaults, lines); + } + } + rows + }, + move |this, rows, cx| { + if this.ticks.seq != req { + return; + } + this.ticks.tape_reading = false; + this.ticks.update_rows(rows); + // The row the fetch job is out for says so again after the fold. + this.mark_fetch_in_flight(); + // The replayable set may have changed under the variant columns: rescore them. + this.arm_ticks_variants(cx); + cx.notify(); + }, + ); + } +} + +/// The held tape of every target, asked from the worker in one batch and collected in order. +/// A query the worker did not answer in time, or one cancelled by a scope change, is absent. +fn held_tapes(targets: &[(Deal, Arc)]) -> HashMap { + let deadline = std::time::Instant::now() + HELD_ANSWER_WAIT; + let asked: Vec<( + i64, + mpsc::Receiver, + Coverage, + )> = targets + .iter() + .filter_map(|(deal, address)| { + let (rx, spans) = ask_held(address, deal)?; + Some((deal.report_uid, rx, spans)) + }) + .collect(); + let asked_n = asked.len(); + let mut out = HashMap::with_capacity(asked_n); + let mut unanswered = 0usize; + for (uid, rx, spans) in asked { + if moon_core::db::current_is_cancelled() { + break; + } + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let Ok(answer) = rx.recv_timeout(remaining) else { + unanswered += 1; + continue; + }; + out.insert(uid, (answer.ticks, answer.covered, spans)); + } + if unanswered > 0 { + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks load: {unanswered} of {asked_n} held queries unanswered within {} s, folded as missing", + HELD_ANSWER_WAIT.as_secs() + ); + } + out +} + +/// One held query sent, with the spans it asked for; the answer arrives on the receiver. +fn ask_held( + address: &RowAddress, + deal: &Deal, +) -> Option<( + mpsc::Receiver, + Coverage, +)> { + let window = replay_window_ms(deal.buy_ms, deal.close_ms, margin_ms())?; + let spans = window.focus_spans(); + let (reply, rx) = mpsc::channel(); + query_held(TickQuery { + exchange_key: address.exchange_key.clone(), + market: address.market.clone(), + spans: spans.clone(), + reply, + }); + Some((rx, spans)) } /// The grid's "now" column: every selected strategy's current value per field, folded to @@ -340,44 +491,49 @@ fn archived_lines(rows: &[DealRow]) -> HashMap { out } +/// The held prints of one deal's window, their coverage, and the spans that were asked for. +type HeldTape = (Vec, Coverage, Coverage); + /// The held prints of one deal's window, through the worker. `None` when the worker did not /// answer in time. -pub(super) fn held_tape( - address: &RowAddress, - deal: &Deal, -) -> Option<(Vec, Coverage, Coverage)> { - let window = replay_window_ms(deal.buy_ms, deal.close_ms, margin_ms())?; - let spans = window.focus_spans(); - let (reply, rx) = mpsc::channel(); - query_held(TickQuery { - exchange_key: address.exchange_key.clone(), - market: address.market.clone(), - spans: spans.clone(), - reply, - }); +pub(super) fn held_tape(address: &RowAddress, deal: &Deal) -> Option { + let (rx, spans) = ask_held(address, deal)?; let answer = rx.recv_timeout(HELD_ANSWER_WAIT).ok()?; Some((answer.ticks, answer.covered, spans)) } -/// Run the model on one row, from what the worker holds; a row without an address is left -/// as it is. A covered row keeps its tape and its archived entry start for the variants. +/// Run the model on one row, from what the worker holds — asked here, one query; a row +/// without an address is left as it is. pub(super) fn replay_row(row: &mut DealRow, defaults: &HashMap, lines: ArchivedLines) { + let tape = row + .address + .as_ref() + .and_then(|address| held_tape(address, &row.deal)); + replay_row_with(row, defaults, lines, tape); +} + +/// Run the model on one row from a tape already asked for. A covered row keeps its tape and +/// its archived entry start for the variants; a row without an address is left as it is. +pub(super) fn replay_row_with( + row: &mut DealRow, + defaults: &HashMap, + lines: ArchivedLines, + tape: Option, +) { row.ticks = None; row.entry_start = lines.entry_start; let Some(address) = row.address.clone() else { return; }; - let Some((ticks, covered, spans)) = held_tape(&address, &row.deal) else { + let Some((ticks, covered, spans)) = tape else { row.tape = TapeStatus::Missing; row.verdict = None; return; }; - let first = ticks.first().map(|t| t.time_ms as i64); - let last = ticks.last().map(|t| t.time_ms as i64); - let complete = covered.covers(&spans) - && first.is_some_and(|f| f <= row.deal.buy_ms - RUN_UP_MS) - && last.is_some_and(|l| l >= row.deal.close_ms); - if !complete { + // Coverage is the worker's own word on what was walked; a quiet run-up with no print in it + // is covered all the same, which the tape's first stamp could not tell from a missing one. + // What must be covered is the model's own rule (`required_spans`), not the whole margin. + if ticks.is_empty() || !covered.covers(&required_spans(&row.deal, &spans)) { row.tape = TapeStatus::Missing; row.verdict = None; return; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index cad78876..da543d9e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -93,16 +93,31 @@ impl AnalyticsView { (list, total, covered, fetchable, without_ms) } }; - let fetch_active = self.ticks.fetch.is_active(); - let fetch_label = if fetch_active { + // The batch is the process's (`fetch::job`), not this window's: the caption reads its + // progress, and says what it is doing right now, not only how far it is — one walk can + // take minutes on a slow venue, and a batch asleep on a venue's backoff has nothing in + // flight at all; a bare "N/M" reads as stuck in both cases. + let progress = fetch::job::progress(); + let fetch_active = progress.active; + let fetch_label = if !fetch_active && self.ticks.tape_reading { + t!("analytics.ticks.fetch_reading").to_string() + } else if !fetch_active { + t!("analytics.ticks.fetch_btn").to_string() + } else if let Some((_, market)) = progress.in_flight { t!( - "analytics.ticks.fetch_progress", - done = self.ticks.fetch.done, - total = self.ticks.fetch.total + "analytics.ticks.fetch_progress_at", + done = progress.done, + total = progress.total, + market = market ) .to_string() } else { - t!("analytics.ticks.fetch_btn").to_string() + t!( + "analytics.ticks.fetch_waiting", + done = progress.done, + total = progress.total + ) + .to_string() }; v_flex() .w_full() diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 095d9415..83eebabc 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -165,35 +165,6 @@ impl TicksData { } } -/// The user's fetch request: which rows are queued, which one is in flight. -#[derive(Default)] -pub(in crate::analytics::tuner) struct FetchQueue { - /// `reportuid`s still to ask for, oldest first. - pub(in crate::analytics::tuner) pending: Vec, - /// The row a request is out for. - pub(in crate::analytics::tuner) in_flight: Option, - /// Rows asked for since the button was pressed, for the "N/M" caption. - pub(in crate::analytics::tuner) done: usize, - pub(in crate::analytics::tuner) total: usize, - /// Bumped on every scope change; an answer carrying an older number is dropped. - pub(in crate::analytics::tuner) seq: u64, -} - -impl FetchQueue { - pub(in crate::analytics::tuner) fn is_active(&self) -> bool { - self.in_flight.is_some() || !self.pending.is_empty() - } - - /// Forget everything queued; an answer in flight is retired by the generation. - pub(in crate::analytics::tuner) fn clear(&mut self) { - self.pending.clear(); - self.in_flight = None; - self.done = 0; - self.total = 0; - self.seq = self.seq.wrapping_add(1); - } -} - /// The search of the axis, as far as the row shows it. pub(in crate::analytics::tuner) enum SuggState { Idle, @@ -261,7 +232,15 @@ pub(in crate::analytics) struct TicksState { /// Whether the two parameter groups are unfolded. pub(in crate::analytics::tuner) entry_open: bool, pub(in crate::analytics::tuner) exit_open: bool, - pub(in crate::analytics::tuner) fetch: FetchQueue, + /// The task listening to the process-wide fetch job (`fetch::job`) for this view; `None` + /// until a batch is started or found running. Dropped with the view, which ends it. + pub(in crate::analytics::tuner) fetch_task: Option>, + /// Whether that task is still in its loop — it ends with the batch, and the next batch + /// attaches a fresh one. + pub(in crate::analytics::tuner) fetch_listening: std::sync::Arc, + /// Whether the tape stage of a load is still reading the rows' tape off the worker: until + /// it folds, every addressed row reads "missing" without meaning it. + pub(in crate::analytics::tuner) tape_reading: bool, } impl Default for TicksState { @@ -292,7 +271,9 @@ impl Default for TicksState { rows_rev: 0, entry_open: true, exit_open: true, - fetch: FetchQueue::default(), + fetch_task: None, + fetch_listening: Default::default(), + tape_reading: false, } } } @@ -303,17 +284,13 @@ impl TicksState { self.dirty = true; } - /// The scope changed: every row and the fetch queue belong to the previous scope. - /// - /// A row a fetch was out for goes back to "missing": its answer will be dropped by the - /// queue's generation, and a stale picture kept across a failed reload must not show a - /// fetch that is not running. + /// The scope changed: every row belongs to the previous scope. The fetch batch is the + /// process's, not the scope's, and runs on; its answers land on rows by id where present. pub(in crate::analytics) fn invalidate(&mut self) { self.dirty = true; self.seq = self.seq.wrapping_add(1); self.rows_rev = self.rows_rev.wrapping_add(1); self.order = None; - self.fetch.clear(); // The variant KPIs and a running search describe the previous scope's deals; the // variant EDITS are the user's and stay, to be rescored over the new scope. self.var_seq = self.var_seq.wrapping_add(1); @@ -415,6 +392,46 @@ impl TicksState { self.order = None; } + /// Fold a batch of answered rows in by id — the tape stage's whole result — with one + /// summary pass rather than one per row. A row not in the table (the scope moved on) is + /// dropped. + pub(in crate::analytics::tuner) fn update_rows(&mut self, answers: Vec) { + let Some(data) = self.data.data_mut() else { + return; + }; + let index: HashMap = data + .rows + .iter() + .enumerate() + .map(|(i, r)| (r.deal.report_uid, i)) + .collect(); + for answer in answers { + let Some(&i) = index.get(&answer.deal.report_uid) else { + continue; + }; + let slot = &mut data.rows[i]; + // The fetch job may have covered the row while this batch was being read, and + // said so through the listener; a read from before its walk must not undo that. + // Coverage only grows between reloads, so the fresher word is the covered one. + if slot.tape == TapeStatus::Covered && answer.tape == TapeStatus::Missing { + continue; + } + slot.tape = answer.tape; + slot.verdict = answer.verdict; + slot.deal.tick = answer.deal.tick; + slot.ticks = answer.ticks; + slot.entry_start = answer.entry_start; + } + data.retain_within_cap(); + data.refresh_summary(); + let kpi = data.kpi.clone(); + if let Some(slot) = self.kpi.data_mut() { + *slot = kpi; + } + self.rows_rev = self.rows_rev.wrapping_add(1); + self.order = None; + } + /// Publish one loaded picture (or its failure) to both load states at once. pub(in crate::analytics::tuner) fn publish( &mut self, diff --git a/locales/analytics.yml b/locales/analytics.yml index cc1a4325..3e17306f 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1716,10 +1716,18 @@ analytics.ticks.fetch_btn: ru: "Прогрузить трейды" en: "Fetch trades" es: "Cargar trades" -analytics.ticks.fetch_progress: - ru: "трейды: %{done}/%{total} · стоп" - en: "trades: %{done}/%{total} · stop" - es: "trades: %{done}/%{total} · parar" +analytics.ticks.fetch_reading: + ru: "лента читается…" + en: "reading the tape…" + es: "leyendo la cinta…" +analytics.ticks.fetch_progress_at: + ru: "трейды: %{done}/%{total} · %{market}… · стоп" + en: "trades: %{done}/%{total} · %{market}… · stop" + es: "trades: %{done}/%{total} · %{market}… · parar" +analytics.ticks.fetch_waiting: + ru: "трейды: %{done}/%{total} · ждём биржу · стоп" + en: "trades: %{done}/%{total} · waiting for the venue · stop" + es: "trades: %{done}/%{total} · esperando al exchange · parar" analytics.ticks.col.time: ru: "вход" en: "entry" From 1611270b4cf833ac890b9792a57a0e083ba90332 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sun, 20 Sep 2026 22:00:41 +0200 Subject: [PATCH 06/51] feat(tuner): tape fetch in parallel lanes, clusters and startup autoload for the Entry/Exit axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/config/storage.rs | 31 +- crates/moon-core/src/config/storage/tests.rs | 10 +- crates/moon-core/src/db/tuner/ticks/deals.rs | 47 +- .../src/db/tuner/ticks/deals/tests.rs | 12 +- crates/moon-core/src/db/tuner/ticks/mod.rs | 13 +- crates/moon-core/src/db/tuner/ticks/scope.rs | 27 - .../src/db/tuner/ticks/scope/tests.rs | 16 - .../src/db/tuner/ticks/tests/real_data.rs | 2 +- .../moon-core/src/market/trade_replay/gate.rs | 23 - .../moon-core/src/market/trade_replay/mod.rs | 54 +- .../src/market/trade_replay/settings.rs | 88 +-- .../src/market/trade_replay/tests.rs | 23 +- .../src/market/trade_replay/venue_caps.rs | 6 +- .../src/market/trade_replay/worker.rs | 143 ++-- crates/moon-ui-gpui/src/analytics/mod.rs | 2 + .../src/analytics/tuner/filter/mod.rs | 34 +- .../moon-ui-gpui/src/analytics/tuner/mod.rs | 2 +- .../src/analytics/tuner/ticks/fetch.rs | 235 ++++++- .../analytics/tuner/ticks/fetch/autoload.rs | 309 +++++++++ .../src/analytics/tuner/ticks/fetch/job.rs | 652 ++++++++++++++---- .../analytics/tuner/ticks/fetch/job/tests.rs | 161 ++++- .../src/analytics/tuner/ticks/load.rs | 70 +- .../src/analytics/tuner/ticks/mod.rs | 146 +++- .../src/analytics/tuner/ticks/state.rs | 6 + crates/moon-ui-gpui/src/load_state.rs | 4 +- crates/moon-ui-gpui/src/settings/storage.rs | 22 +- crates/moon-ui-gpui/src/startup/boot.rs | 6 +- locales/analytics.yml | 16 + locales/storage.yml | 62 +- 29 files changed, 1642 insertions(+), 580 deletions(-) create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs diff --git a/crates/moon-core/src/config/storage.rs b/crates/moon-core/src/config/storage.rs index 336bbebb..598e4f5d 100644 --- a/crates/moon-core/src/config/storage.rs +++ b/crates/moon-core/src/config/storage.rs @@ -61,21 +61,17 @@ pub struct TradeReplayStoreCfg { /// Ceiling on the packed prints the file may hold, in megabytes; past it the spans written /// longest ago go first. `0` keeps everything, with no age limit. pub max_mb: u32, - /// Seconds of prints kept around a trade, per end: a short position gets this much before - /// its entry and after its exit; a long one (held past [`Self::long_position_min`]) gets - /// this much on both sides of each end, with bars between. It sizes what a trade window - /// fetches, what a close copies out of the core's ring, and what the file keeps. One of - /// [`TRADE_MARGIN_STEPS_S`]: a hand-edited value is snapped to the nearest step on load. - pub margin_s: u32, - /// Minutes a position may be held and still count as SHORT; held past them it is LONG — - /// walked as its two ends with bars between, by a trade window and by the close-time capture. - /// Bounded to [`LONG_POSITION_MIN_RANGE`] on load; a file written before the field reads - /// as the default, which is what the threshold was while it was a constant. - pub long_position_min: u32, - /// Whether the terminal runs the Storage tab's cleanup on its own once the cores are up. - /// Off by default: it rewrites the file unasked. A file written before the field reads as - /// off. - pub cleanup_at_startup: bool, + /// Minutes of prints kept around a trade, per end: a short position gets this many minutes + /// before its entry and after its exit; a long one (over an hour) gets this many minutes + /// centred on each end, half before and half after, with bars between. It sizes what a trade + /// window fetches, what a close copies out of the core's ring, and what the file keeps. + /// `0` is the position alone; clamped to [`MAX_TRADE_MARGIN_MIN`] on load. + pub margin_min: u32, + /// Whether the terminal fetches, once the cores are up, the tape of every recent closed + /// trade with millisecond stamps that the venues still serve — what the close-time capture + /// missed because the terminal was not running. Off by default: it spends the venues' public + /// request budget without being asked. A file written before the field reads as off. + pub autoload_missing: bool, } /// Default minutes of [`TradeReplayStoreCfg::long_position_min`]: the five minutes the @@ -116,9 +112,8 @@ impl Default for TradeReplayStoreCfg { Self { persist_trades: true, max_mb: DEFAULT_TRADES_MAX_MB, - margin_s: DEFAULT_TRADE_MARGIN_S, - long_position_min: DEFAULT_LONG_POSITION_MIN, - cleanup_at_startup: false, + margin_min: DEFAULT_TRADE_MARGIN_MIN, + autoload_missing: false, } } } diff --git a/crates/moon-core/src/config/storage/tests.rs b/crates/moon-core/src/config/storage/tests.rs index c268518d..6e1b0bd0 100644 --- a/crates/moon-core/src/config/storage/tests.rs +++ b/crates/moon-core/src/config/storage/tests.rs @@ -16,13 +16,9 @@ max_mb = 512 assert_eq!(cfg.trade_replay.margin_s, DEFAULT_TRADE_MARGIN_S); assert_eq!(cfg.trade_replay.max_mb, 512); assert!(cfg.trade_replay.persist_trades); - // The long-position threshold came after the margin and reads as the five minutes it was as - // a constant; the startup cleanup may not switch itself on. - assert_eq!( - cfg.trade_replay.long_position_min, - DEFAULT_LONG_POSITION_MIN - ); - assert!(!cfg.trade_replay.cleanup_at_startup); + // The autoload switch came after the margin and reads as OFF from a file without it: it + // spends the venues' budget, and may not switch itself on. + assert!(!cfg.trade_replay.autoload_missing); } /// The long-position threshold round-trips through the file and is bounded where `load` diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index 839b4faf..c92405fb 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -2,12 +2,17 @@ //! //! Read through the same unified source the other axes scan (`read_tuner_rows`), so the //! "Fact" column and the tape replay describe the SAME trades — period, cores, strategies, -//! emulator and side filters included. A row without a millisecond stamp cannot be replayed -//! (the tape is sub-second) and is counted rather than dropped silently; the caption prints -//! the count. +//! emulator and side filters included. Three kinds of row are counted rather than dropped +//! silently, and the caption and the load log print the counts: a row without a millisecond +//! stamp cannot be replayed (the tape is sub-second); a SERVICE row — funding, a liquidation, a +//! joined sell, no strategy behind it — is not a trade the tape explains ([`scope`]); and a +//! trade the tuner cannot be run on — a container kind, an unresolved one, a manual exit. The +//! "Fact" column keeps them all: it is the scope's money, and this file decides only what the +//! model reads. use rusqlite::Connection; +use super::scope::{is_service_row, is_tunable}; use super::{Deal, Deltas}; use crate::db::analytics::Query; use crate::db::read_fail::read_fail_on; @@ -22,6 +27,12 @@ pub struct DealsRead { /// Rows the scope holds that carry no millisecond stamp — older replicas, or a core that /// predates the stamps. In the "Fact" column, not in the replay. pub without_ms: usize, + /// Service rows with stamps — funding, liquidations, joined sells, no strategy — left out; + /// see [`scope`]. + pub service: usize, + /// Trades with stamps the tuner cannot be run on — a container or unresolved kind, a manual + /// exit — left out; see [`is_tunable`]. + pub untunable: usize, } /// The delta columns in the order [`Deltas`] is filled below; every one is a `FIELDS` column, @@ -41,14 +52,15 @@ const DELTA_COLS: [&str; 12] = [ "exchange1hdelta", ]; -/// Read the scope's closed trades as deals. +/// Read the scope's closed trades as deals: the trades the tuner can be run on +/// ([`is_tunable`]), the rest counted in [`DealsRead::untunable`]. /// /// Args: /// q: The tuner scope — period, cores, strategies, filters. /// /// Returns: -/// The replayable deals with their kinds resolved, and the count left out; `NotReady` when -/// no report source has the schema yet. +/// The replayable deals with their kinds resolved, and the counts left out; `NotReady` +/// when no report source has the schema yet. pub fn read_deals(q: &Query) -> ReadResult { let mut read = crate::db::tuner::read_tuner_rows(q, read_on)?; // The kind selects the entry model; resolved once per distinct strategy, off the replica's @@ -66,6 +78,10 @@ pub fn read_deals(q: &Query) -> ReadResult { deal.kind = kind.clone(); } } + let before = read.deals.len(); + read.deals + .retain(|deal| is_tunable(&deal.kind, &deal.sell_reason)); + read.untunable = before - read.deals.len(); Ok(read) } @@ -107,6 +123,18 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { out.without_ms += 1; continue; } + let strategy_id = int(2)?; + let sell_reason = r + .get::<_, Option>(10) + .map_err(fail)? + .unwrap_or_default(); + // Counted after the stamp gate on purpose: the caption's "without stamps" is the + // scope's whole unreplayable history, the service count only what the stamps would + // otherwise have admitted. + if is_service_row(strategy_id, &sell_reason) { + out.service += 1; + continue; + } let mut deltas = Deltas::default(); let slots: [&mut f64; 12] = [ &mut deltas.d5s, @@ -129,7 +157,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { out.deals.push(Deal { report_uid, core_uid: int(1)? as u64, - strategy_id: int(2)?, + strategy_id, kind: String::new(), coin: r .get::<_, Option>(3) @@ -141,10 +169,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { sell_price: num(7)?, spent: num(8)?, is_short: int(9)? != 0, - sell_reason: r - .get::<_, Option>(10) - .map_err(fail)? - .unwrap_or_default(), + sell_reason, fact_pnl: num(11)?, deltas, tick: None, diff --git a/crates/moon-core/src/db/tuner/ticks/deals/tests.rs b/crates/moon-core/src/db/tuner/ticks/deals/tests.rs index f519e2d3..d4b98e36 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals/tests.rs @@ -20,7 +20,11 @@ fn replica() -> Connection { (12, 7, 42, 'BEN', 150, 180, 150000, 180000, 50.0, 49.0, 500.0, -10.0, 1, 'Auto Price Down', 1, NULL, -1.0, 0.0), (13, 7, 42, 'OLD', 110, 190, 0, 0, 1.0, 1.1, 100.0, 10.0, 0, - 'Sell Price', 1, 0.0, 0.0, 0.0);", + 'Sell Price', 1, 0.0, 0.0, 0.0), + (14, 7, 42, 'ACE', 160, 170, 160000, 170000, 99.0, 99.0, 1000.0, -0.3, 0, + 'Funding', 1, 0.0, 0.0, 0.0), + (15, 7, 0, 'BEN', 165, 175, 165000, 175000, 50.0, 51.0, 500.0, 10.0, 0, + 'Manual Sell', 1, 0.0, 0.0, 0.0);", ) .expect("fixture"); conn @@ -41,6 +45,12 @@ fn rows_with_stamps_become_deals_and_the_rest_are_counted() { let (q, src) = tuner_source_on(&conn, &scope()).expect("source"); let read = read_on(&conn, &q, &src).expect("read"); assert_eq!(read.without_ms, 1, "the row without millisecond stamps"); + // The funding row and the no-strategy manual sell carry stamps and are still not deals. + assert_eq!(read.service, 2, "the service rows"); + assert_eq!( + read.untunable, 0, + "the kind gate is applied after the kinds resolve, not here" + ); assert_eq!(read.deals.len(), 2); // Chronological by the millisecond close: BEN (180 000) before ACE (200 000). assert_eq!(read.deals[0].report_uid, 12); diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index db8f63cc..ee6ced4e 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -31,6 +31,7 @@ pub mod exit; pub mod line; pub mod mshot; pub mod params; +pub mod scope; pub mod search; pub mod stats; pub mod verify; @@ -40,6 +41,7 @@ pub use entry::{EntryModel, entry_model_for}; pub use exit::{ExitModel, ExitParams}; pub use mshot::{MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; +pub use scope::{is_service_row, is_tunable}; pub use search::{PreparedDeal, SearchParams, SearchResult, suggest, variant_tally}; pub use stats::{fact_stats, stats_of}; pub use verify::{Verdict, verify}; @@ -207,19 +209,22 @@ pub enum EntryParams { MoonShot(MshotParams), } -/// The tape must reach this far back before the buy for the corridor to have a run-up. -pub const RUN_UP_MS: i64 = 30_000; +/// The tape must reach this far back before the buy for the corridor to have a run-up. The +/// replay worker walks exactly this much as part of the trade for a model's request +/// (`trade_replay::MODEL_PAD_MS`), so the two are one number. +pub const RUN_UP_MS: i64 = crate::market::trade_replay::MODEL_PAD_MS; /// The tape must reach this far past the close for the exit to have a tail: a line the fact /// crossed at the close is reproduced by a print at or before it, but a near miss a few seconds /// later must be judged on its price — [`verify`] counts a model still open when the tape ends /// as a miss of the exit group, and a tape cut at the close would turn every such near miss /// into one. -pub const TAIL_MS: i64 = 30_000; +pub const TAIL_MS: i64 = crate::market::trade_replay::MODEL_PAD_MS; /// The part of a deal's window the model cannot do without: the run-up before the buy through /// the tail after the close, clipped to what the window asks for at all (a long position asks -/// only around its two ends, and a zero margin asks for the position alone). The rest of the +/// only around its two ends; a model's window is built with a margin of at least the pads, see +/// `trade_replay::model_margin_ms`). The rest of the /// window — the trail beyond the tail, the lead beyond the run-up — is served as far as the /// tape goes: a venue's page budget runs out on the trail of a pumped coin long before the /// margin, and a variant that outlives the tape is marked open at the window's end. diff --git a/crates/moon-core/src/db/tuner/ticks/scope.rs b/crates/moon-core/src/db/tuner/ticks/scope.rs index e2f79a17..27eba392 100644 --- a/crates/moon-core/src/db/tuner/ticks/scope.rs +++ b/crates/moon-core/src/db/tuner/ticks/scope.rs @@ -23,33 +23,6 @@ pub const SERVICE_SELL_REASONS: [&str; 3] = ["Funding", "LIQUIDATION", "JoinedSe /// Strategy kinds (`SignalType`) that hold no trading rule of their own. const CONTAINER_KINDS: [&str; 3] = ["Manual", "Alerts", "Watcher"]; -/// How much more than it bought a row may sell and still be one position: the fraction the -/// exchange's own rounding of a lot can add. Anything past it is coins the core topped the sale -/// up with from the wallet balance. -const SOLD_OVER_BOUGHT_EPS: f64 = 1e-6; - -/// Whether the row sold MORE coins than it bought. -/// -/// On spot, a position that comes out under the exchange's minimum lot is topped up from the -/// wallet balance, and the sale then covers coins this trade never bought: its `sellprice` is an -/// average over a different amount, and the level the model is judged against is not the level -/// the rule placed. Such a row is excluded like a manual sell — the tape cannot explain it. -/// -/// Measured on this machine's replica (2026-09-22): 1 row of 606 767 by this signature, so the -/// exclusion costs nothing here and matters on a spot core that does it often. The opposite -/// direction — selling slightly LESS — is ordinary: the fee is taken in coin, and 2 242 rows sit -/// a fraction below their bought amount. -/// -/// Args: -/// quantity: The row's `quantity` — what the sale moved. -/// bought: The row's `boughtq` — what the entry filled. -pub fn sold_more_than_bought(quantity: f64, bought: f64) -> bool { - quantity.is_finite() - && bought.is_finite() - && bought > 0.0 - && quantity > bought * (1.0 + SOLD_OVER_BOUGHT_EPS) -} - /// Whether a report row is a service row — never a deal of the axis. /// /// Args: diff --git a/crates/moon-core/src/db/tuner/ticks/scope/tests.rs b/crates/moon-core/src/db/tuner/ticks/scope/tests.rs index 27a15364..26fada1a 100644 --- a/crates/moon-core/src/db/tuner/ticks/scope/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/scope/tests.rs @@ -9,22 +9,6 @@ fn service_rows_are_the_no_strategy_and_the_named_reasons() { assert!(is_service_row(42, "Funding")); assert!(is_service_row(42, "LIQUIDATION")); assert!(is_service_row(42, "JoinedSell")); -} - -/// A spot sale topped up from the wallet balance moved coins the entry never bought, so its -/// price is an average of something else. -#[test] -fn a_sale_bigger_than_its_entry_is_not_a_deal() { - assert!(sold_more_than_bought(101.0, 100.0)); - assert!(!sold_more_than_bought(100.0, 100.0), "the ordinary case"); - // The fee is taken in coin on spot, so selling slightly LESS is normal. - assert!(!sold_more_than_bought(99.88, 100.0)); - // Nothing to compare against is not a finding. - assert!(!sold_more_than_bought(101.0, 0.0)); - assert!(!sold_more_than_bought(f64::NAN, 100.0)); - assert!(!sold_more_than_bought(101.0, f64::INFINITY)); - // Float noise on a lot must not read as a top-up. - assert!(!sold_more_than_bought(100.0 + 1e-9, 100.0)); assert!(!is_service_row(42, "Auto Price Down")); assert!(!is_service_row(42, "Sell Price")); assert!(!is_service_row(42, "StopLoss Market Sell")); diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index c40f102b..e6b592b2 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -116,7 +116,7 @@ fn real_data_reproduction() { .expect("pairs") .flatten() .collect(); - let margin_ms = crate::market::trade_replay::margin_ms(); + let margin_ms = crate::market::trade_replay::model_margin_ms(); let keys = param_keys(); let defaults = HashMap::new(); diff --git a/crates/moon-core/src/market/trade_replay/gate.rs b/crates/moon-core/src/market/trade_replay/gate.rs index d6bb4fe5..30e69065 100644 --- a/crates/moon-core/src/market/trade_replay/gate.rs +++ b/crates/moon-core/src/market/trade_replay/gate.rs @@ -215,9 +215,6 @@ impl ReplayGate { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let previous = claims.get(host).copied(); - if let Some(remaining) = refused_for(previous.as_ref(), now) { - return Err(remaining); - } claims.insert( host, Attempt { @@ -248,26 +245,6 @@ impl ReplayGate { refused_for(claims.get(host), now) } - /// Forget a host's refusal history after it answered successfully. - /// - /// The number a requester waits out before asking again — the same one [`Self::claim`] - /// would refuse with right now — read after a walk stopped on the gate, where a second - /// `claim` would take the permit the moment the wait ended. - /// - /// Args: - /// host: Stable host key, from the route. - /// now: Current instant. - /// - /// Returns: - /// Remaining seconds, or `None` when a permit may be taken now. - pub fn refused_for(&self, host: &'static str, now: Instant) -> Option { - let claims = self - .claims - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - refused_for(claims.get(host), now) - } - /// Forget a host's refusal history after it answered a request sent at `asked_at`, AFTER /// the refusal. /// diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index e4ad2275..124ea780 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -42,8 +42,7 @@ use crate::market::candles::ChartCandle; use crate::market::{CandleReadParams, ChartHistoryBuffers, ChartHistoryRead}; use crate::venue::{Brand, Venue}; pub use coverage::Coverage; -use std::sync::OnceLock; -use std::sync::atomic::{AtomicU32, Ordering}; +pub use settings::{margin_ms, model_margin_ms, set_margin_min, set_tape_autoload, tape_autoload}; pub use worker::{TickAnswer, TickQuery, query_held}; /// Milliseconds in one minute, the only timeframe a replay is fetched at. @@ -79,24 +78,20 @@ const MAX_SPAN_MS: i64 = 7 * 24 * 60 * MINUTE_MS; /// exit — so a window clipped exactly to the position would answer the wrong question. const CONTEXT_FRACTION: f64 = 0.5; -// A position held longer than `[trade_replay] long_position_min` ([`long_position_ms`]) asks -// for ticks only around its entry and its exit ([`ReplayWindow::focus_spans`]), each end -// getting the window's margin on both sides of it; the middle stays bars. -// -// A meaning bound, not a resource one: the page budget already caps what a walk can fetch, but -// on a multi-hour position it burned out ~40 minutes after the entry and the exit came back as -// bars — while at the zoom such a position is viewed at, the chart draws bars for the middle -// anyway. Five minutes was the developer's call as a constant (2026-09-21; an hour the day -// before) and is the default now that the Storage tab moves it: past it the ticks between the -// ends are a ribbon nobody reads, and what matters is how the entry and the exit printed. The -// tuner's model runs a long position on that two-end tape as it is — also the developer's -// call, the same day: an exit that really happened in the unwalked middle is a miss in the -// replay, and the deal table's "held" column shows how far the tape reaches on each side. -// -// The tuner's fetch clusters several trades of one market into one request whose open is the -// first entry and whose close is the last exit, and keeps the cluster within the same -// threshold: a longer one would be walked as two ends, and the trades in between would go -// without their tape. +/// A position held longer than this asks for ticks only around its entry and its exit +/// ([`ReplayWindow::focus_spans`]), each end getting the window's margin centred on it; the +/// middle stays bars. +/// +/// A meaning bound, not a resource one: the page budget already caps what a walk can fetch, but +/// on a multi-hour position it burned out ~40 minutes after the entry and the exit came back as +/// bars — while at the zoom such a position is viewed at, the chart draws bars for the middle +/// anyway. One hour is the developer's call (2026-09-20): past it the ticks between the ends +/// are a ribbon nobody reads, and what matters is how the entry and the exit printed. +/// +/// Public because the tuner's fetch clusters several trades of one market into one request +/// whose open is the first entry and whose close is the last exit: a cluster longer than this +/// would be walked as two ends, and the trades in between would go without their tape. +pub const LONG_POSITION_MS: i64 = 60 * MINUTE_MS; /// How far before the entry and past the exit a MODEL's request treats the tape as part of the /// trade itself (walked under the trade budget, never cut short by the normal page ceiling): @@ -251,7 +246,7 @@ pub enum TradeReplayOutcome { /// Who is asking for the prints, which decides three things the requester cannot express in /// the window alone: which margin the walk spends its page budget on first, whether a short /// answer waits for the core's archive, and whether the remembered-answer ring is consulted. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ReplayIntent { /// A live trade window. The exit's trail is walked before the entry's lead — the part of the /// picture the eye lands on — and an answer short of the focus keeps polling the core's @@ -271,6 +266,15 @@ impl ReplayIntent { matches!(self, Self::Model) } + /// How far outside the position the trade tiles reach — see [`MODEL_PAD_MS`]. A chart's + /// trade tiles are the position alone. + pub(crate) fn trade_pad_ms(self) -> i64 { + match self { + Self::Chart => 0, + Self::Model => MODEL_PAD_MS, + } + } + /// Whether an answer short of the focus arms the bounded core-archive follow-up. pub(crate) fn awaits_core(self) -> bool { matches!(self, Self::Chart) @@ -303,10 +307,10 @@ pub struct ReplayWindow { /// Millisecond-exact when the core supplied a millisecond column, whole seconds otherwise. pub close_ms: i64, /// How many milliseconds of prints are asked for around the position, per end — the - /// `[trade_replay] margin_s` setting at the moment the window was built ([`margin_ms`]). - /// A short position gets this much before the entry and after the exit ([`Self::focus`]); - /// a long one gets it on both sides of each end ([`Self::focus_spans`]). Zero is the position - /// alone — a value the setting no longer offers, but one a hand-built window may still carry. + /// `[trade_replay] margin_min` setting at the moment the window was built (floored for a + /// model's window, see `model_margin_ms`). A short position + /// gets this much before the entry and after the exit ([`Self::focus`]); a long one gets it + /// centred on each end ([`Self::focus_spans`]). Zero is the position alone. pub margin_ms: i64, /// How long a position must be held to be walked as its two ends — the `[trade_replay] /// long_position_min` setting at the moment the window was built ([`long_position_ms`]), diff --git a/crates/moon-core/src/market/trade_replay/settings.rs b/crates/moon-core/src/market/trade_replay/settings.rs index b0178f59..31e67cb9 100644 --- a/crates/moon-core/src/market/trade_replay/settings.rs +++ b/crates/moon-core/src/market/trade_replay/settings.rs @@ -8,14 +8,13 @@ use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -/// Live value of `[trade_replay] margin_s` — how many seconds of prints a window asks for +use super::MINUTE_MS; + +/// Live value of `[trade_replay] margin_min` — how many minutes of prints a window asks for /// around a trade, per end ([`super::ReplayWindow::margin_ms`]). -static MARGIN_S: AtomicU32 = AtomicU32::new(crate::config::storage::DEFAULT_TRADE_MARGIN_S); -/// Live value of `[trade_replay] long_position_min`. -static LONG_POSITION_MIN: AtomicU32 = - AtomicU32::new(crate::config::storage::DEFAULT_LONG_POSITION_MIN); -/// Live value of `[trade_replay] cleanup_at_startup`. -static CLEANUP_AT_STARTUP: AtomicBool = AtomicBool::new(false); +static MARGIN_MIN: AtomicU32 = AtomicU32::new(crate::config::storage::DEFAULT_TRADE_MARGIN_MIN); +/// Live value of `[trade_replay] autoload_missing`. +static TAPE_AUTOLOAD: AtomicBool = AtomicBool::new(false); static INIT: OnceLock<()> = OnceLock::new(); /// Load the file into the cells once; every setter calls it first, or the file's value would @@ -23,70 +22,49 @@ static INIT: OnceLock<()> = OnceLock::new(); fn init() { INIT.get_or_init(|| { let cfg = crate::config::storage::load(); - MARGIN_S.store(cfg.trade_replay.margin_s, Ordering::Relaxed); - LONG_POSITION_MIN.store(cfg.trade_replay.long_position_min, Ordering::Relaxed); - CLEANUP_AT_STARTUP.store(cfg.trade_replay.cleanup_at_startup, Ordering::Relaxed); - // Once per launch, so a file migrated from `margin_min` shows what it was read as. - log::info!( - "[x] trade-replay settings: margin {} s, long position from {} min, cleanup at startup {}", - cfg.trade_replay.margin_s, - cfg.trade_replay.long_position_min, - cfg.trade_replay.cleanup_at_startup - ); + MARGIN_MIN.store(cfg.trade_replay.margin_min, Ordering::Relaxed); + TAPE_AUTOLOAD.store(cfg.trade_replay.autoload_missing, Ordering::Relaxed); }); } -/// The configured margin, in milliseconds — what every new [`super::ReplayWindow`] is built -/// with: a chart's, a tuner's, the close-time capture's, and the cleanup's claims. Its floor is -/// the model's pad ([`super::MODEL_PAD_MS`], `config::storage::TRADE_MARGIN_STEPS_S`), so every -/// position carries the whole run-up and tail — a long one gets the margin on both sides of each -/// end ([`super::ReplayWindow::focus_spans`]). +/// The configured margin, in milliseconds — what every new [`super::ReplayWindow`] and every +/// close-time capture is built with. pub fn margin_ms() -> i64 { init(); - i64::from(MARGIN_S.load(Ordering::Relaxed)) * 1_000 + i64::from(MARGIN_MIN.load(Ordering::Relaxed)) * MINUTE_MS } -/// Move the live margin; the Storage tab writes `storage.toml` beside this. Windows already open -/// keep the margin they were built with; the next one asks for the new stretch, and the tile -/// store hands back what earlier windows already fetched of it. Snapped onto the step list like -/// the file is on load, so the cell never holds a value the tab cannot show. -pub fn set_margin_s(secs: u32) { - init(); - MARGIN_S.store( - crate::config::storage::snap_trade_margin_s(secs), - Ordering::Relaxed, - ); +/// The margin a MODEL's window is built with: the chart's margin, but never less than TWICE +/// the model's own pad ([`super::MODEL_PAD_MS`]). The plan's trade tiles and the model's +/// required span are both clipped to the window's focus, so a chart margin under the pad — +/// "the position alone" is a valid setting — would otherwise leave the model without its +/// run-up and its tail and never say so. Twice, because a long position's focus centres the +/// margin on each end ([`super::ReplayWindow::focus_spans`]): half of it lies outside the +/// position, and that half must still be a whole pad. +pub fn model_margin_ms() -> i64 { + margin_ms().max(2 * super::MODEL_PAD_MS) } -/// How long a position must be held to be walked as its two ends — `[trade_replay] -/// long_position_min`, in milliseconds. Captured where a window is built -/// ([`super::replay_window_ms`] → [`super::ReplayWindow::long_position_ms`]) and read from the -/// window from then on, like the margin: a request is clustered, walked, judged and drawn at -/// different moments, and every one of them must split it the same way. -pub fn long_position_ms() -> i64 { - init(); - i64::from(LONG_POSITION_MIN.load(Ordering::Relaxed)) * 60_000 -} - -/// Move the live threshold; the Storage tab writes `storage.toml` beside this. Bounded like -/// the file is on load. -pub fn set_long_position_min(minutes: u32) { +/// Move the live margin; the Storage tab writes `storage.toml` beside this. Windows already open +/// keep the margin they were built with; the next one asks for the new stretch, and the tile +/// store hands back what earlier windows already fetched of it. +pub fn set_margin_min(minutes: u32) { init(); - LONG_POSITION_MIN.store( - crate::config::storage::clamp_long_position_min(minutes), + MARGIN_MIN.store( + minutes.min(crate::config::storage::MAX_TRADE_MARGIN_MIN), Ordering::Relaxed, ); } -/// Whether the terminal runs the trade-tape cleanup on its own once the cores are up — -/// `[trade_replay] cleanup_at_startup`. -pub fn cleanup_at_startup() -> bool { +/// Whether the terminal fetches the tape of recent closed trades on its own once the cores are +/// up — `[trade_replay] autoload_missing`. +pub fn tape_autoload() -> bool { init(); - CLEANUP_AT_STARTUP.load(Ordering::Relaxed) + TAPE_AUTOLOAD.load(Ordering::Relaxed) } -/// Move the live startup-cleanup switch; the Storage tab writes the file beside this. -pub fn set_cleanup_at_startup(on: bool) { +/// Move the live autoload switch; the Storage tab writes the file beside this. +pub fn set_tape_autoload(on: bool) { init(); - CLEANUP_AT_STARTUP.store(on, Ordering::Relaxed); + TAPE_AUTOLOAD.store(on, Ordering::Relaxed); } diff --git a/crates/moon-core/src/market/trade_replay/tests.rs b/crates/moon-core/src/market/trade_replay/tests.rs index 09d0dd84..c234d079 100644 --- a/crates/moon-core/src/market/trade_replay/tests.rs +++ b/crates/moon-core/src/market/trade_replay/tests.rs @@ -1167,6 +1167,9 @@ fn tick_plan_of_a_long_position_tiles_the_entry_and_the_exit_only() { /// A model's request walks the entry's lead before the exit's trail on a forward route — the /// run-up is what its entry model reads — and the lead is still walked away from the trade, so /// every completed prefix stays one stretch. A backward route already walks the lead first. +/// The model's trade tile reaches [`MODEL_PAD_MS`] outside the position — the run-up and the +/// tail are walked under the trade budget, never as an optional margin; a chart's is the +/// position alone. #[test] fn tick_plan_of_a_model_request_walks_the_lead_before_the_trail() { use venue_caps::TradeRoute::*; @@ -1174,23 +1177,29 @@ fn tick_plan_of_a_model_request_walks_the_lead_before_the_trail() { let close_ms = open_ms + MINUTE_MS; let window = replay_window_ms(open_ms, close_ms, MARGIN_MS).expect("window"); let (focus_from, focus_to) = window.focus(); - let lead = (focus_from, open_ms - 1); - let trail = (close_ms + 1, focus_to); - let trade = (open_ms, close_ms); + let padded = (open_ms - MODEL_PAD_MS, close_ms + MODEL_PAD_MS); + let lead = (focus_from, padded.0 - 1); + let trail = (padded.1 + 1, focus_to); let forward = tick_plan(window, BinanceUsdMAggTrades, None, ReplayIntent::Model); - assert_eq!(forward.slices, vec![trade, lead, trail]); + assert_eq!(forward.slices, vec![padded, lead, trail]); assert_eq!((forward.trade_len, forward.focus_len), (1, 3)); + let trade = (open_ms, close_ms); let chart = tick_plan(window, BinanceUsdMAggTrades, None, ReplayIntent::Chart); assert_eq!( chart.slices, - vec![trade, trail, lead], - "the chart keeps its order" + vec![trade, (close_ms + 1, focus_to), (focus_from, open_ms - 1)], + "the chart keeps its order and its bare trade tile" ); let backward = tick_plan(window, OkxHistoryTrades, None, ReplayIntent::Model); assert_eq!( backward.slices, + vec![padded, lead, trail], + "a backward route walks the lead first for either intent, the model's tile padded" + ); + assert_eq!( tick_plan(window, OkxHistoryTrades, None, ReplayIntent::Chart).slices, - "a backward route walks the lead first for either intent" + vec![trade, (focus_from, open_ms - 1), (close_ms + 1, focus_to)], + "and the chart's bare" ); } diff --git a/crates/moon-core/src/market/trade_replay/venue_caps.rs b/crates/moon-core/src/market/trade_replay/venue_caps.rs index e83ae06e..c3810791 100644 --- a/crates/moon-core/src/market/trade_replay/venue_caps.rs +++ b/crates/moon-core/src/market/trade_replay/venue_caps.rs @@ -342,13 +342,9 @@ impl TradeRoute { Self::BinanceUsdMAggTrades | Self::BinanceCoinMAggTrades => { std::time::Duration::from_millis(650) } - // Not a weight limit: under back-to-back requests the futures trades endpoint - // answered a repeat of the previous page for a changed `offset` (2026-09-21, see - // the route table), and never did 300 ms apart; a page is 1 000 rows, so the - // floor costs a busy minute of tape a third of a second. - Self::GateFuturesTrades => std::time::Duration::from_millis(350), Self::BinanceSpotAggTrades | Self::GateSpotTrades + | Self::GateFuturesTrades | Self::BitgetSpotFills | Self::BitgetMixFills | Self::OkxHistoryTrades => super::gate::MIN_INTERVAL, diff --git a/crates/moon-core/src/market/trade_replay/worker.rs b/crates/moon-core/src/market/trade_replay/worker.rs index a84f3876..80fb1e5f 100644 --- a/crates/moon-core/src/market/trade_replay/worker.rs +++ b/crates/moon-core/src/market/trade_replay/worker.rs @@ -257,9 +257,8 @@ const CAPTURE_SETTLE_SLACK: Duration = Duration::from_secs(5); /// /// The Entry/Exit tuner's question: is this trade's window covered, and if so, hand me the /// prints. Asked once per report row of a table, so it must cost a lock and a disk read, never -/// a page. The answer goes to the worker's queue like every other job because the tile store -/// lives on the worker's thread; it is served right after the candle jobs, ahead of any tick -/// walk, so a table of rows is not held behind one window's venue paging. +/// a page. It goes to the coordinator's queue, which no venue call ever holds: the walks run +/// on the lanes, so a table of rows is answered while every venue is being paged. pub struct TickQuery { /// The exchange half of the tile key — [`ReplayAddress::exchange_key`]. pub exchange_key: String, @@ -281,11 +280,58 @@ pub struct TickAnswer { pub covered: Coverage, } -/// What reaches the worker's one inbound channel. +/// What reaches the coordinator's one inbound channel. enum Inbound { Replay(TradeReplayRequest), Capture(CaptureRequest), Held(TickQuery), + /// A lane armed a native follow-up for a request it answered; the coordinator polls it. + /// Boxed for the same reason as [`Job::Native`]. + NativeWait(Box<(TradeReplayRequest, NativeWait)>), +} + +/// One unit of a lane's own queue: the venue calls of one request. +/// +/// A candle job and its own tick upgrade are two separate units on purpose: queuing the tick +/// stage inline would make a second report-row double-click on the same host wait behind it for +/// its OWN candles — see [`next_lane_job`], which is what keeps candle jobs strictly ahead. +enum LaneJob { + Candles(TradeReplayRequest), + /// The stage is boxed: it carries the window's bars, several times the request's size. + Ticks(TradeReplayRequest, Box), +} + +/// What every lane shares with the coordinator and with each other. The gate and the two +/// stores were built for one thread and are already behind their own locks; nothing here is +/// thread-affine. +struct Shared { + agent: ureq::Agent, + gate: ReplayGate, + cache: Mutex>, + tiles: Mutex, + /// Back to the coordinator, for the native follow-ups a lane arms. + back: Sender, +} + +/// The handle to one lane thread. +struct Lane { + tx: Sender, +} + +/// What a lane serves: one host's calls of one intent. +type LaneKey = (&'static str, ReplayIntent); + +/// The lane key of a request: the kline route's host — the budget every call of the request +/// is metered under (the trade route derives its host from the same table) — and the intent, +/// so a chart window and the tuner's batch on the same host walk side by side. A venue with no +/// route answers `NoEndpoint` without a call and shares one idle lane per intent. +fn lane_key(request: &TradeReplayRequest) -> LaneKey { + ( + kline_route(request.address.venue) + .map(|route| route.host()) + .unwrap_or(""), + request.intent, + ) } /// One bounded native follow-up independent of public tick-route eligibility. @@ -330,9 +376,8 @@ impl NativeWait { } } -/// Pop the next unit of work, by kind: every pending [`Job::Candles`], then every -/// [`Job::Held`], then every [`Job::Native`], then the rest ([`Job::Ticks`], [`Job::Capture`]) -/// in arrival order; oldest first within each kind. +/// Pop the coordinator's next unit of work, by kind: every pending [`Job::Held`], then every +/// [`Job::Native`], then the captures in arrival order; oldest first within each kind. /// /// Args: /// queue: The coordinator's own pending-work deque. @@ -340,10 +385,9 @@ impl NativeWait { /// Returns: /// The next job to run, or `None` when the queue is empty. fn next_job(queue: &mut VecDeque) -> Option { - // A held-data query costs a lock and a disk read; it goes ahead of the native probes and the - // tick walks so a table asking once per row is not paced by one window's venue paging. + // A held-data query costs a lock and a disk read; it goes ahead of the native probes so a + // table asking once per row is answered at once. for pick in [ - |job: &Job| matches!(job, Job::Candles(_)), |job: &Job| matches!(job, Job::Held(_)), |job: &Job| matches!(job, Job::Native(..)), ] { @@ -354,6 +398,25 @@ fn next_job(queue: &mut VecDeque) -> Option { queue.pop_front() } +/// Pop a lane's next unit of work: every pending candle job first, then the tick stages in +/// arrival order — a second window on the same host gets its bars before the first window's +/// paging starts. +/// +/// Args: +/// queue: The lane's own pending-work deque. +/// +/// Returns: +/// The next job to run, or `None` when the queue is empty. +fn next_lane_job(queue: &mut VecDeque) -> Option { + if let Some(index) = queue + .iter() + .position(|job| matches!(job, LaneJob::Candles(_))) + { + return queue.remove(index); + } + queue.pop_front() +} + /// Why a tick stage stopped, logged for partial harvests as well as empty abandonments. /// /// `Cancelled` throws away whatever was collected because the window itself closed. Every other @@ -595,15 +658,13 @@ fn enqueue( /// Coordinator loop: an internal priority queue, forever. /// -/// Candle, held-data, tick, bounded native follow-up and capture jobs share one queue: candles -/// first, then held-data queries, then native probes, then ticks and captures in arrival order -/// ([`next_job`]). A tick stage is a separate job rather than an inline continuation of its -/// candle job so the first outcome reaches its window before any venue paging starts. A capture is an in-process -/// copy out of a core's ring, milliseconds, so it never holds a tick stage up for long; the -/// settle pass of each capture is timed (`settle_waits`) and enters the queue when due. Idle -/// waits end at the next native probe or settle deadline; otherwise every already-queued -/// request is drained non-blockingly first, so a burst of report-row clicks is batched into -/// the queue before priority is applied rather than served one at a time. +/// Held-data queries, bounded native follow-ups and captures share this queue: held-data first, +/// then native probes, then captures in arrival order ([`next_job`]); none of them calls a +/// venue, so none waits for a lane. A replay request is handed to its host's lane on arrival +/// ([`enqueue`]), and a lane hands back the native follow-up it arms. A capture is an in-process +/// copy out of a core's ring, milliseconds; the settle pass of each capture is timed +/// (`settle_waits`) and enters the queue when due. Idle waits end at the next native probe or +/// settle deadline; otherwise every already-queued message is drained non-blockingly first. /// /// Args: /// rx: Queue of pending requests. @@ -667,33 +728,6 @@ fn run(rx: &Receiver, back: Sender) { continue; }; match job { - Job::Candles(request) => { - // A window that closed while its request sat in the queue costs nothing at all: - // this is the cheapest of the three cancellation guards and the only one that - // prevents the work. - if request.cancel.load(Ordering::Relaxed) { - continue; - } - let mut served = serve_with_core(&agent, &gate, &cache, &request); - // No native wait either with the stage off: the wait is the core-archive half - // of the same stage, and it would poll the archive for a window that asked for - // the bars alone. - let native_wait = match request.ticks { - true => arm_native_wait(&request, &mut served, Instant::now()), - false => None, - }; - // The receiver is gone whenever the window closed mid-fetch. Normal, not an - // error — and exactly the signal that a queued tick stage would now answer no - // one, so it is never queued on a failed send. - let sent = request.reply.send(served.outcome).is_ok(); - if sent { - if let Some(stage) = served.tick_stage { - queue.push_back(Job::Ticks(request, stage)); - } else if let Some(wait) = native_wait { - native_waits.push((request, wait)); - } - } - } Job::Capture(request, spans, settle_pass) => { for &span in spans.spans() { capture_from_core(&request, span, tiles); @@ -719,11 +753,12 @@ fn run(rx: &Receiver, back: Sender) { } } Job::Held(query) => { - let answer = held_answer(&tiles, &query); + let answer = held_answer(tiles, &query); // A dead receiver is the asker gone — a closed table — and costs nothing more. let _ = query.reply.send(answer); } - Job::Native(request, mut wait) => { + Job::Native(native) => { + let (request, mut wait) = *native; if request.cancel.load(Ordering::Relaxed) { continue; } @@ -850,7 +885,7 @@ fn run_lane(rx: &Receiver, shared: &Shared) { // The same holds when the walk was abandoned and the tile store served // the part of the focus it held: what it did not hold is still owed. remember_store( - &cache, + cache, request.intent, stage.key, Remembered::Ready { @@ -884,7 +919,7 @@ fn run_lane(rx: &Receiver, shared: &Shared) { // the fetch itself did not produce an answer, so a reopen must retry it. if status == TickStatus::NoTrades { remember_store( - &cache, + cache, request.intent, stage.key, Remembered::Ready { @@ -1472,10 +1507,10 @@ fn tick_stage_for( /// trade held ~10 h and closed 40 h ago (retention 48 h) was refused outright although its /// exit's ticks were comfortably inside retention. /// -/// Free. Not a gate of the tick stage — a window past the retention is still served from the -/// tiles ([`tick_stage_for`]) — but of a model's fetch ([`super::ReplayIntent::Model`], the -/// tuner's): it is asked before a row is queued or called fetchable, so a row the venue would -/// refuse anyway pays no candle page ahead of the refusal. +/// Free, and evaluated BEFORE any request is spent — see [`tick_stage_for`]. Public for the +/// tuner's startup autoload, which asks it before queueing a row at all, so a row the stage +/// would refuse anyway does not pay the candle page ahead of the refusal — the ONE rule, not a +/// second one beside it. /// /// Args: /// route: The trade route in question. diff --git a/crates/moon-ui-gpui/src/analytics/mod.rs b/crates/moon-ui-gpui/src/analytics/mod.rs index 8c7f7f48..44025b26 100644 --- a/crates/moon-ui-gpui/src/analytics/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/mod.rs @@ -35,6 +35,8 @@ mod toolbar; /// The former flat set of `strategies`/`tuner*`/`strat_time`/`time_tuner` modules at the /// analytics root now lives under `tuner/`. mod tuner; +/// The tape autoload of the Entry/Exit axis, driven from the coordination tick. +pub(crate) use tuner::ticks::fetch::autoload as tape_autoload; // Pages reach these through the familiar `super::…`, unaware of the `period` module. pub(in crate::analytics) use period::{ diff --git a/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs index e18331cd..8aaab5f2 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs @@ -83,7 +83,9 @@ impl AnalyticsView { q } - /// Read numeric strategy-field defaults used to hide unconfigured threshold chips. + /// Read numeric strategy-field defaults used to hide unconfigured threshold chips — the + /// same table the Entry/Exit model fills defaulted fields from + /// (`ticks::fetch::strategy_field_defaults`). /// /// Args: /// cx: GPUI context used to read the backend schema store. @@ -94,35 +96,7 @@ impl AnalyticsView { &self, cx: &Context, ) -> HashMap { - let backend = self.backend.read(cx); - let store = backend.session.store(); - let mut defaults = HashMap::new(); - for (_, core) in store.cores() { - let Some(schema) = core.schema.as_ref() else { - continue; - }; - for kind in &schema.kinds { - for section in &kind.sections { - for field in §ion.fields { - let Some(default) = field.default.as_ref() else { - continue; - }; - if let Ok(value) = default - .trim() - .trim_end_matches('%') - .replace(',', ".") - .parse::() - { - defaults - .entry(field.name.to_ascii_lowercase()) - .or_insert(value); - } - } - } - } - break; - } - defaults + super::ticks::strategy_field_defaults(&self.backend.read(cx)) } /// Recompute only KPI and selected strategy thresholds after a local tuner edit. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs index a1d84013..261d5c6f 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs @@ -41,7 +41,7 @@ mod coins; /// "By filter": the threshold grid, its histogram and its auto-suggestion. mod filter; /// "By time": the weekly schedule grid, the hour profile and the sliders. -mod ticks; +pub(in crate::analytics) mod ticks; mod time; // State types held by `AnalyticsView` (the parent). diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index 95fc1ca9..86803c24 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -4,8 +4,11 @@ //! exchange identity, the contract terms), hands the rows to the process-wide job, and listens: //! a row that lands is folded into the table by id, whatever scope the table shows by then. The //! job outlives the window; a reopened window attaches a fresh listener and reads the same -//! progress for its caption. +//! progress. The resolution itself ([`FetchResolver`]) needs no view — the startup autoload +//! ([`autoload`]) runs it off the coordination tick with no Analytics window open. +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use std::sync::atomic::Ordering; use std::sync::mpsc; use std::time::Duration; @@ -13,66 +16,224 @@ use std::time::Duration; use gpui::*; use super::super::super::AnalyticsView; -use super::state::TapeStatus; -use moon_core::market::trade_replay::{margin_ms, replay_window_ms}; +use super::state::{RowAddress, TapeStatus}; +use crate::Backend; +use moon_core::db::tuner::ticks::Deal; +use moon_core::market::MarketDataSource; +use moon_core::market::trade_replay::{model_margin_ms, replay_window_ms}; +pub(crate) mod autoload; pub(in crate::analytics::tuner) mod job; /// How long one blocking receive holds a background-pool thread before checking back. const LISTEN_SLICE: Duration = Duration::from_secs(2); +/// What a deal needs from the live session to become a request: the market source for the +/// exchange identity, the catalog and the contract terms, and each core's quote setting for +/// spelling the coin into a market. A snapshot of handles, not of state — the source is a +/// shared handle, so a core that connects after this was built resolves on the next ask. +pub(in crate::analytics::tuner) struct FetchResolver { + source: MarketDataSource, + /// `core_uid` to the core's quote (`ServerConfig::market`). + quotes: HashMap, + /// Per distinct `(core, coin)`, what it resolved to — a table of one coin's trades asks + /// the catalog once. + addresses: HashMap<(u64, String), Option>>, +} + +impl FetchResolver { + pub(in crate::analytics::tuner) fn of(backend: &Backend) -> Self { + Self { + source: backend.session.market_source(), + quotes: backend + .config + .servers + .iter() + .map(|s| (s.id, s.market.clone())) + .collect(), + addresses: HashMap::new(), + } + } + + /// Where a deal's prints live: the core's exchange key and the catalog-verified market. A + /// core that is not connected, or a coin its catalog does not spell, resolves to nothing + /// and the row says so ([`TapeStatus::NoAddress`]). + pub(in crate::analytics::tuner) fn address(&mut self, deal: &Deal) -> Option> { + let key = (deal.core_uid, deal.coin.clone()); + if let Some(address) = self.addresses.get(&key) { + return address.clone(); + } + let quote = self + .quotes + .get(&deal.core_uid) + .map(String::as_str) + .unwrap_or_default(); + let address = self + .source + .replay_address(deal.core_uid) + .ok() + .and_then(|address| { + let market = self + .source + .resolve_market(deal.core_uid, quote, &deal.coin)?; + let tick = self.source.price_step(deal.core_uid, &market); + Some(Arc::new(RowAddress { + core_uid: deal.core_uid, + exchange_key: address.exchange_key, + market, + tick, + })) + }); + self.addresses.insert(key, address.clone()); + address + } + + /// The request for one addressed deal — the same addressing a trade window resolves: the + /// live source for the exchange identity, the core's contract terms for how the prints are + /// valued. `None` when the deal's stamps describe no window, or the core went away between + /// the address and now. + pub(in crate::analytics::tuner) fn queued_row( + &self, + deal: Deal, + address: Arc, + ) -> Option { + let window = replay_window_ms(deal.buy_ms, deal.close_ms, model_margin_ms())?; + let replay_address = self.source.replay_address(address.core_uid).ok()?; + let terms = self + .source + .market_contract_terms(address.core_uid, &address.market); + let tick_value = moon_core::market::trade_replay::venue_caps::tick_value( + replay_address.venue, + terms.as_ref().map(|(quote, size)| (quote.as_str(), *size)), + ); + Some(job::QueuedRow::new( + deal, + address, + replay_address, + tick_value, + window, + )) + } +} + +/// Numeric strategy-field defaults off the first core schema — what the model fills a field +/// the strategy leaves at default with, and what the tuner's filter hides unconfigured chips +/// by. Lowercase field names to values. +pub(in crate::analytics::tuner) fn strategy_field_defaults( + backend: &Backend, +) -> HashMap { + let store = backend.session.store(); + let mut defaults = HashMap::new(); + for (_, core) in store.cores() { + let Some(schema) = core.schema.as_ref() else { + continue; + }; + for kind in &schema.kinds { + for section in &kind.sections { + for field in §ion.fields { + let Some(default) = field.default.as_ref() else { + continue; + }; + if let Ok(value) = default + .trim() + .trim_end_matches('%') + .replace(',', ".") + .parse::() + { + defaults + .entry(field.name.to_ascii_lowercase()) + .or_insert(value); + } + } + } + } + break; + } + defaults +} + impl AnalyticsView { - /// Queue every fetchable row of the table with the job, oldest first. + /// Queue every fetchable row of the table with the job, oldest first: a fresh batch when + /// none runs, or added to the running one — the startup autoload's, or a previous window's + /// — which already knows some of them and takes the rest. pub(in crate::analytics::tuner) fn ticks_fetch_missing(&mut self, cx: &mut Context) { // Until the tape stage has folded, every row reads "missing": queuing them would ask the // venue for tape the tiles already hold. - if job::progress().active || self.ticks.tape_reading { + if self.ticks.tape_reading { return; } let Some(data) = self.ticks.data.data() else { return; }; - // The same addressing a trade window resolves: the live source for the exchange - // identity, the core's contract terms for how the prints are valued. let backend = self.backend.read(cx); - let source = backend.session.market_source(); + let resolver = FetchResolver::of(&backend); let mut rows: Vec = data .fetchable() - .filter_map(|row| { - let address = row.address.clone()?; - let window = replay_window_ms(row.deal.buy_ms, row.deal.close_ms, margin_ms())?; - let replay_address = source.replay_address(address.core_uid).ok()?; - let terms = source.market_contract_terms(address.core_uid, &address.market); - let tick_value = moon_core::market::trade_replay::venue_caps::tick_value( - replay_address.venue, - terms.as_ref().map(|(quote, size)| (quote.as_str(), *size)), - ); - Some(job::QueuedRow::new( - row.deal.clone(), - address, - replay_address, - tick_value, - window, - )) - }) + .filter_map(|row| resolver.queued_row(row.deal.clone(), row.address.clone()?)) .collect(); // Oldest first, so the ones nearest the venues' retention edge go before it moves; the // job pops from the end. rows.reverse(); - let defaults = self.filter_defaults(cx); - if job::start(rows, defaults) { + let wanted: HashSet = rows.iter().map(|r| r.deal.report_uid).collect(); + let defaults = strategy_field_defaults(&backend); + if job::enqueue(rows, defaults) > 0 { self.attach_fetch_listener(cx); } + // The user asked for THESE rows: before whatever else their venues hold. + job::prioritize(&wanted); cx.notify(); } - /// Abandon the batch; the request in flight is cancelled by the job. + /// Mark the table's still-missing rows for a running batch — the ones the user is looking + /// at go before the rest of their venues' queues. A no-op while no batch runs. + pub(in crate::analytics::tuner) fn ticks_prioritize_visible(&self) { + if !job::progress().active { + return; + } + let Some(data) = self.ticks.data.data() else { + return; + }; + let wanted: HashSet = data + .rows + .iter() + .filter(|r| matches!(r.tape, TapeStatus::Missing | TapeStatus::Fetching)) + .map(|r| r.deal.report_uid) + .collect(); + let moved = job::prioritize(&wanted); + if moved > 0 { + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks fetch: {moved} row(s) of the open table marked to go first" + ); + } + } + + /// How many fetchable rows of the table a running batch does not know yet — what a press + /// would add. Zero while no batch runs (then every fetchable row is what a press starts). + pub(in crate::analytics::tuner) fn ticks_fetch_addable(&self) -> usize { + let Some(data) = self.ticks.data.data() else { + return 0; + }; + // While the tape stage reads, every row is "missing" without meaning it, and a press + // would add nothing (`ticks_fetch_missing` waits for the fold): no button then. + if self.ticks.tape_reading || !job::progress().active { + return 0; + } + let known = job::known_uids(); + data.fetchable() + .filter(|row| !known.contains(&row.deal.report_uid)) + .count() + } + + /// Abandon the batch; every request in flight is cancelled by the job, and the startup + /// autoload, if it still has rows to add, adds none. pub(in crate::analytics::tuner) fn ticks_fetch_stop(&mut self, cx: &mut Context) { - // Read before the stop: the job clears its in-flight row on its own thread the moment - // the cancelled walk returns, and no row event follows a cancellation. + // Read before the stop: the job clears its in-flight rows on their own threads the + // moment the cancelled walks return, and no row event follows a cancellation. let in_flight = job::progress().in_flight; job::stop(); - if let Some((uid, _)) = in_flight { + autoload::cancel(); + for uid in in_flight.into_iter().flat_map(|(uids, _)| uids) { self.ticks.update_row(uid, |row| { if row.tape == TapeStatus::Fetching { row.tape = TapeStatus::Missing; @@ -121,11 +282,15 @@ impl AnalyticsView { while let Ok(event) = rx.try_recv() { events.push(event); } + // A notify on every hop, events or not: the caption names the markets in + // flight, and a walk that takes a minute changes nothing else the view could + // hear. let applied = cx.update(|cx| { this.update(cx, |this, cx| { for event in events { this.apply_fetch_event(event, cx); } + cx.notify(); }) }); // The view is gone; the job goes on without a listener. @@ -172,9 +337,13 @@ impl AnalyticsView { cx.notify(); } - /// Mark the row the job is out for, after the table was rebuilt. + /// Mark the rows the job is out for, after the table was rebuilt. pub(in crate::analytics::tuner) fn mark_fetch_in_flight(&mut self) { - if let Some((uid, _)) = job::progress().in_flight { + for uid in job::progress() + .in_flight + .into_iter() + .flat_map(|(uids, _)| uids) + { self.ticks.update_row(uid, |row| { if row.tape == TapeStatus::Missing { row.tape = TapeStatus::Fetching; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs new file mode 100644 index 00000000..30d8e2bf --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs @@ -0,0 +1,309 @@ +//! The startup autoload: the tape of recent closed trades the close-time capture missed, +//! fetched on the terminal's own initiative once the cores are up. +//! +//! The close-time capture (`session::lifecycle::capture_closed_trade`) files a trade's prints +//! out of the core's ring the moment it closes — but only while the terminal is running. What +//! closed in between is gone from the ring by the next launch and can only come from the venue, +//! and the tuner's axis would otherwise show those rows as missing until someone pressed +//! "Fetch trades". Behind `[trade_replay] autoload_missing` (off by default: it spends the +//! venues' public budget unasked), this runs ONCE per process, from the coordination tick: +//! +//! 1. read every closed trade with millisecond stamps of the last [`HORIZON_MS`] across every +//! core, under the axis' own filter — strategy trades the tuner can be run on; +//! 2. resolve each through the live source, keep the ones the venue still serves by the +//! worker's own retention rule (`inside_retention`: the exit inside the route's retention; a +//! venue with no route is skipped — nothing to ask), and hand them to the fetch job +//! ([`super::job::enqueue`]) — the job asks the +//! worker, and the worker serves what the tiles and `trades.sqlite` already hold without a +//! request, so a trade the capture DID file costs nothing here; +//! 3. a trade whose core is not connected yet, or whose catalog is not in, is kept and tried +//! again every [`RETRY`] for up to [`MAX_ATTEMPTS`]: the cores come up one by one after the +//! terminal, and the catalog a little after each core. +//! +//! The read and the resolution run on the background executor; the tick only decides whether +//! one is due. "Stop" on the axis' button cancels what remains ([`cancel`]); flipping the +//! switch off and on again re-arms it. + +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use gpui::App; + +use super::job; +use super::{FetchResolver, strategy_field_defaults}; +use crate::Backend; +use moon_core::db::tuner::ticks::Deal; +use moon_core::market::trade_replay::venue_caps::trade_route; +use moon_core::market::trade_replay::worker::inside_retention; +use moon_core::market::trade_replay::{model_margin_ms, replay_window_ms}; + +/// How far back the autoload looks, whatever the venue documents: the longest retention a +/// route names is 90 days, and a month of rows is already thousands of walks. +const HORIZON_MS: i64 = 30 * 24 * 3_600_000; + +/// How long the first pass waits after the tick first finds the switch on — for the cores to +/// come up and report their catalogs, so the first pass resolves most rows at once. +const FIRST_DELAY: Duration = Duration::from_secs(20); + +/// Between passes over the rows still unresolved. +const RETRY: Duration = Duration::from_secs(30); + +/// Passes before the rows still unresolved are given up on: ten minutes of cores not coming. +const MAX_ATTEMPTS: u32 = 20; + +/// Where the autoload stands. +#[derive(Default)] +enum Phase { + /// The switch has not been seen on yet, or was seen off since. + #[default] + Armed, + /// Waiting for the next pass, with what is left to resolve (`None` before the first read). + Waiting { + due: Instant, + left: Option>, + attempts: u32, + }, + /// A pass is on the background executor. + Running, + /// Every row was handed over or given up on, or the user stopped it. + Done, +} + +#[derive(Default)] +struct Autoload { + phase: Phase, + /// What the running pass hands back: the rows still unresolved, and the pass count. + result: Option<(Vec, u32)>, + /// Bumped by every start of a pass, by a cancel and by the switch going off: a pass carries + /// the generation it started under and is heard only while it is still the current one — + /// a pass the user stopped, or that the switch outlived, neither enqueues nor reports. + generation: u64, +} + +static AUTOLOAD: OnceLock> = OnceLock::new(); + +fn lock() -> std::sync::MutexGuard<'static, Autoload> { + AUTOLOAD + .get_or_init(|| Mutex::new(Autoload::default())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Stop adding rows: what the job already has stays the job's to finish or to drop, and a pass +/// still running adds nothing when it comes back. +pub(crate) fn cancel() { + let mut st = lock(); + if !matches!(st.phase, Phase::Armed) { + st.phase = Phase::Done; + st.generation += 1; + st.result = None; + } +} + +/// The coordination tick's call: start, continue or finish the autoload. Cheap when nothing is +/// due — a switch read and a clock compare. +pub(crate) fn tick(backend: &Backend, cx: &App) { + let on = moon_core::market::trade_replay::tape_autoload(); + let mut st = lock(); + if !on { + // Off re-arms: switched on again later, it starts over from a fresh read; a pass still + // running under the old generation is not heard. + if !matches!(st.phase, Phase::Armed) { + st.generation += 1; + } + st.phase = Phase::Armed; + st.result = None; + return; + } + let now = Instant::now(); + match std::mem::take(&mut st.phase) { + Phase::Armed => { + st.phase = Phase::Waiting { + due: now + FIRST_DELAY, + left: None, + attempts: 0, + }; + } + Phase::Waiting { + due, + left, + attempts, + } if due <= now => { + st.phase = Phase::Running; + st.generation += 1; + let generation = st.generation; + start_pass(backend, left, attempts, generation, cx); + } + waiting @ Phase::Waiting { .. } => st.phase = waiting, + Phase::Running => match st.result.take() { + None => st.phase = Phase::Running, + Some((left, attempts)) => { + st.phase = match (left.is_empty(), attempts) { + (true, _) => Phase::Done, + (false, attempts) if attempts >= MAX_ATTEMPTS => { + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks autoload: {} row(s) never resolved after {attempts} passes — cores not connected or catalogs without the coin; given up", + left.len() + ); + Phase::Done + } + (false, attempts) => Phase::Waiting { + due: now + RETRY, + left: Some(left), + attempts, + }, + }; + } + }, + Phase::Done => st.phase = Phase::Done, + } +} + +/// One pass on the background executor: read the deals (first pass only), resolve, hand over, +/// report what is left — unless the pass was cancelled or outlived meanwhile. +fn start_pass( + backend: &Backend, + left: Option>, + attempts: u32, + generation: u64, + cx: &App, +) { + let resolver = FetchResolver::of(backend); + let defaults = strategy_field_defaults(backend); + let axis = backend.report_axis(chrono_tz::UTC); + let attempt = attempts + 1; + cx.background_executor() + .spawn(async move { + let left = run_pass(resolver, defaults, axis, left, attempt, generation); + let mut st = lock(); + if st.generation == generation { + st.result = Some((left, attempt)); + } + }) + .detach(); +} + +/// The pass itself, off the UI thread. +/// +/// Returns: +/// The deals still unresolved — to try again — or nothing when every one was handed over, +/// skipped, or the read failed (a failed read is logged and not retried: the replica is +/// not going to change its mind in thirty seconds, and the axis' own load will say why). +fn run_pass( + mut resolver: FetchResolver, + defaults: std::collections::HashMap, + axis: moon_core::db::ReportAxis, + left: Option>, + attempt: u32, + generation: u64, +) -> Vec { + let now_ms = moon_core::util::time::now_unix_ms_i64(); + let deals = match left { + Some(left) => left, + None => match read_recent(axis, now_ms) { + Ok(deals) => deals, + Err(error) => { + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks autoload: read failed, not retried: {error:?}" + ); + return Vec::new(); + } + }, + }; + let total = deals.len(); + let mut rows = Vec::new(); + let mut unresolved = Vec::new(); + let mut no_route = 0usize; + let mut out_of_retention = 0usize; + let mut degenerate = 0usize; + for deal in deals { + // Stamps that describe no window are the row's own fault, final: not a core that is + // still coming, so never retried. + if replay_window_ms(deal.buy_ms, deal.close_ms, model_margin_ms()).is_none() { + degenerate += 1; + continue; + } + // Either `None` is the core not connected yet, or its catalog not spelling the coin + // yet: the row waits for the next pass. + let Some(address) = resolver.address(&deal) else { + unresolved.push(deal); + continue; + }; + let Some(row) = resolver.queued_row(deal.clone(), address) else { + unresolved.push(deal); + continue; + }; + let Some(route) = trade_route(row.replay_address.venue) else { + no_route += 1; + continue; + }; + // The worker's own rule, asked here only to spare the candle page a refused row would + // pay first: the exit inside the route's retention. Not the entry — a trade held across + // the retention edge still gets its exit's tape, and what the model then lacks is the + // model's own verdict, the same as through the button. + if !inside_retention(route, row.window, now_ms) { + out_of_retention += 1; + continue; + } + rows.push(row); + } + // Newest-first is the job's queue order: it pops from the end, oldest first. + rows.sort_by_key(|row| std::cmp::Reverse(row.deal.close_ms)); + let offered = rows.len(); + // Checked right before the hand-over, not at the start: the resolution above can take a + // while, and a Stop pressed during it means these rows are not wanted. The autoload's lock + // is HELD across the hand-over, so a Stop cannot slip between the check and the queue: + // `cancel` waits for it, then bumps the generation, and the job it then stops already + // holds these rows. The nesting is one-way (autoload, then job) — `ticks_fetch_stop` + // takes them one after the other, never the job's inside the autoload's. + let queued = { + let st = lock(); + if st.generation != generation { + drop(st); + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks autoload pass {attempt}: stopped before the hand-over, {offered} row(s) not queued" + ); + return Vec::new(); + } + job::enqueue(rows, defaults) + }; + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks autoload pass {attempt}: {total} deal(s) considered, {queued} queued ({} already in the batch), {no_route} with no route, {out_of_retention} past the venue's retention, {degenerate} with no window, {} unresolved (core not connected or catalog without the coin)", + offered - queued, + unresolved.len() + ); + unresolved +} + +/// The candidates: every closed trade with millisecond stamps of the last [`HORIZON_MS`], on +/// every core, under the axis' own filters. +fn read_recent( + axis: moon_core::db::ReportAxis, + now_ms: i64, +) -> Result, moon_core::db::ReadFail> { + let now_s = now_ms.div_euclid(1_000); + let q = moon_core::db::analytics::Query { + axis, + from: now_s - HORIZON_MS.div_euclid(1_000), + // Exclusive, and a day ahead: a core's clock a little ahead of this machine's must not + // hide the trade that closed a minute ago. + to: now_s + 86_400, + // Percent needs no quote projection, so a fleet of mixed quotes reads in one pass. + metric: moon_core::db::ProfitMetric::Percent, + ..Default::default() + }; + let read = moon_core::db::tuner::ticks::read_deals(&q)?; + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks autoload read: {} deal(s) with ms stamps in the last {} days, {} service, {} not tunable", + read.deals.len(), + HORIZON_MS / 86_400_000, + read.service, + read.untunable + ); + Ok(read.deals) +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs index 4d743d6e..dd0a2cbc 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -6,15 +6,29 @@ //! progress live on one background thread that outlives every window; a view only hands it rows //! (resolved while the view still had the live source), listens for what lands, and reads the //! progress for its caption. Closing the window drops the listener, nothing else; reopening it -//! attaches a new one and reads the same progress. +//! attaches a new one and reads the same progress. The startup autoload ([`enqueue`]) adds rows +//! to the same queue; a batch is whatever is in it, wherever it came from. //! -//! One row at a time, because the replay worker is one thread and the venues are rate-limited. -//! A venue that refuses (the gate's backoff, or its own error mid-walk) puts its rows aside for -//! the gate's own number of seconds while the rows of other venues go on; the refused row goes -//! back first once the wait is out, and a row the venue itself refused is asked once more before -//! it counts as final. +//! One request at a time PER VENUE, several venues at once. The replay worker walks the hosts +//! in parallel lanes and paces each on its own, so two requests on one exchange key would only +//! queue behind each other there while the second one's slot could have been another venue's. +//! A request is a CLUSTER: the next pending row of a free key — a row the user is looking at +//! first ([`prioritize`]), else the oldest — plus every pending row of the +//! same market whose window overlaps it, as long as the first entry and the last exit stay +//! within one hour ([`LONG_POSITION_MS`], past which the worker walks only the two ends). One +//! walk of the whole stretch serves them all — a pumped coin closes dozens of trades in an +//! hour, and asked one by one each of them re-walked the same minutes and paid the same page +//! budget, and on a venue with small pages (OKX, 100 prints) each of them died on that budget +//! in turn. Every row of the cluster is then replayed off the tiles on its own and answered on +//! its own. A venue that refuses (the gate's backoff, or its own error mid-walk) puts its rows +//! aside for the gate's own number of seconds while the rows of other venues go on; the +//! refused rows go back first once the wait is out, and a row the venue itself refused is asked +//! once more before it counts as final. A walk the worker cut short on its own page budget or +//! deadline — a pumped coin on a venue with small pages — is CONTINUED: the rows it did not +//! reach go back to the end of their venue's turn, and the next walk picks up where the tiles +//! end, for as long as each walk gains tape ([`MAX_CONTINUATIONS`] at most). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex, OnceLock, mpsc}; use std::time::{Duration, Instant}; @@ -27,14 +41,24 @@ use moon_core::market::ReplayAddress; use moon_core::market::trade_replay::venue_caps::TickValue; use moon_core::market::trade_replay::worker::{self, TradeReplayRequest}; use moon_core::market::trade_replay::{ - ReplayIntent, ReplayWindow, TickStatus, TradeReplayEmpty, TradeReplayFailure, - TradeReplayOutcome, + Coverage, LONG_POSITION_MS, ReplayIntent, ReplayWindow, TickStatus, TradeReplayEmpty, + TradeReplayFailure, TradeReplayOutcome, replay_window_ms, }; /// The wait after a venue's own refusal mid-walk, when the gate names no number: the gate's own /// floor, so the second ask lands after the backoff it will have recorded. const VENUE_REFUSAL_WAIT: Duration = Duration::from_secs(30); +/// How many times a row goes back for the rest of its tape after a walk that stopped on the +/// worker's own budget. Each continuation is a walk of up to the trade budget (240 pages); +/// twelve of them are ~20 minutes of a pumped coin's tape on OKX (100 prints a page, ~240 a +/// second measured 2026-09-20), which is more than any one position needs. +const MAX_CONTINUATIONS: u8 = 12; + +/// Ceiling on the rows out at once, whatever the number of exchange keys: one per key is the +/// rule, this is the guard against a fleet on many small venues fanning into many threads. +const MAX_IN_FLIGHT: usize = 8; + /// One deal as the job asks for it: everything the request needs, resolved by the view while it /// still had the live source for the exchange identity and the contract terms. pub(in crate::analytics::tuner) struct QueuedRow { @@ -45,6 +69,14 @@ pub(in crate::analytics::tuner) struct QueuedRow { pub(in crate::analytics::tuner) window: ReplayWindow, /// Whether the venue itself already refused this row once; the second refusal is final. retried: bool, + /// How many walks were continued for this row's tape, and how much of the request's focus + /// the last walk's answer covered — a continuation must gain on it. + continued: u8, + covered_ms: i64, + /// Whether the user is looking at this row — the dispatcher takes such rows before the + /// rest of their venue's queue ([`prioritize`]). A mark, not a position: it survives a + /// deferral and a continuation, which reorder the queue. + priority: bool, } impl QueuedRow { @@ -62,6 +94,9 @@ impl QueuedRow { tick_value, window, retried: false, + continued: 0, + covered_ms: 0, + priority: false, } } } @@ -84,8 +119,9 @@ pub(in crate::analytics::tuner) struct Progress { /// Rows answered since the batch started, and the batch's size. pub(in crate::analytics::tuner) done: usize, pub(in crate::analytics::tuner) total: usize, - /// The row a request is out for: its id and its market. - pub(in crate::analytics::tuner) in_flight: Option<(i64, String)>, + /// The requests out, in the order they went out: the ids of the rows each one serves, and + /// its market. + pub(in crate::analytics::tuner) in_flight: Vec<(Vec, String)>, } /// A row set aside until its venue's wait is out. @@ -94,14 +130,21 @@ struct Deferred { due: Instant, } +/// A request out for a cluster of rows. +struct InFlight { + uids: Vec, + market: String, + exchange_key: String, + /// Raised by a stop so the walk ends at once. + cancel: Arc, +} + #[derive(Default)] struct State { /// Rows still to ask for; popped from the end, so the caller orders them newest-first. pending: Vec, deferred: Vec, - in_flight: Option<(i64, String)>, - /// The in-flight request's cancel flag, raised by a stop so the walk ends at once. - in_flight_cancel: Option>, + in_flight: Vec, done: usize, total: usize, /// The strategy-field defaults the model runs with, captured when the batch started. @@ -117,7 +160,7 @@ struct State { impl State { fn active(&self) -> bool { - self.in_flight.is_some() || !self.pending.is_empty() || !self.deferred.is_empty() + !self.in_flight.is_empty() || !self.pending.is_empty() || !self.deferred.is_empty() } fn notify(&mut self, event: JobEvent) { @@ -154,6 +197,34 @@ impl State { self.pending.extend(due.into_iter().map(|d| d.row)); any } + + /// The next row that may go out — see [`pick_dispatchable`]. + fn dispatchable(&self) -> Option { + let busy: HashSet<&str> = self + .in_flight + .iter() + .map(|f| f.exchange_key.as_str()) + .collect(); + pick_dispatchable( + self.pending + .iter() + .map(|row| (row.address.exchange_key.as_str(), row.priority)), + &busy, + self.in_flight.len(), + ) + } + + /// Every id the batch already knows — queued, waiting, out, or answered — so a row is + /// never asked for twice by two sources of rows. + fn known(&self) -> HashSet { + self.pending + .iter() + .map(|r| r.deal.report_uid) + .chain(self.deferred.iter().map(|d| d.row.deal.report_uid)) + .chain(self.in_flight.iter().flat_map(|f| f.uids.iter().copied())) + .chain(self.finished.iter().map(|(uid, _)| *uid)) + .collect() + } } struct Job { @@ -188,47 +259,99 @@ fn ensure_thread() { }); } -/// Start a batch, unless one is running. `rows` is newest-first: the job pops from the end, -/// so the oldest — nearest the venues' retention edge — goes first. +/// Add rows to the batch — the running one, or a fresh one when none runs. Rows the batch +/// already knows (queued, waiting, out, or answered since it started) are dropped, so the +/// startup autoload and a "Fetch trades" press cannot ask for the same trade twice. `rows` is +/// newest-first: the job pops from the end, so the oldest — nearest the venues' retention edge +/// — goes first. `defaults` are taken only when they open a fresh batch. /// /// Returns: -/// Whether the batch was taken. -pub(in crate::analytics::tuner) fn start( +/// How many rows were added. +pub(in crate::analytics::tuner) fn enqueue( rows: Vec, defaults: HashMap, -) -> bool { +) -> usize { if rows.is_empty() { - return false; + return 0; } ensure_thread(); let job = job(); let mut st = lock(job); - if st.active() { - return false; + if !st.active() { + st.total = 0; + st.done = 0; + st.deferred.clear(); + st.finished.clear(); + st.defaults = defaults; + st.stop = false; } - st.total = rows.len(); - st.done = 0; - st.pending = rows; - st.deferred.clear(); - st.finished.clear(); - st.defaults = defaults; - st.stop = false; + let known = st.known(); + let fresh: Vec = rows + .into_iter() + .filter(|row| !known.contains(&row.deal.report_uid)) + .collect(); + let added = fresh.len(); + if added == 0 { + return 0; + } + // In FRONT of what is queued: `pending` pops from the end, and the rows already there — + // the user's own press, or an earlier autoload pass — keep their turn. + let mut pending = fresh; + pending.append(&mut st.pending); + st.pending = pending; + st.total += added; st.notify(JobEvent::Progress); drop(st); job.wake.notify_all(); - true + added +} + +/// Mark the rows among `uids` as the ones the user is looking at: the dispatcher takes a +/// marked row of a free venue before the venue's other rows, oldest marked row first. The +/// autoload queues a month of trades oldest-first, so the freshest rows, the ones at the top +/// of a table, would otherwise be the last of hundreds; a press of "Fetch trades" and every +/// load of the axis mark theirs. A mark rather than a move: a venue's backoff sweeps rows out +/// of the queue and back, and a continuation re-queues a row — a position would not survive +/// either, the mark does. Rows out, answered or unknown are left alone. +/// +/// Returns: +/// How many rows were newly marked, queued or waiting. +pub(in crate::analytics::tuner) fn prioritize(uids: &HashSet) -> usize { + if uids.is_empty() { + return 0; + } + let job = job(); + let mut st = lock(job); + let mut marked = 0usize; + let State { + pending, deferred, .. + } = &mut *st; + for row in pending + .iter_mut() + .chain(deferred.iter_mut().map(|d| &mut d.row)) + { + if !row.priority && uids.contains(&row.deal.report_uid) { + row.priority = true; + marked += 1; + } + } + drop(st); + if marked > 0 { + job.wake.notify_all(); + } + marked } -/// Abandon the batch: the queue empties, the request in flight is cancelled and its answer is -/// dropped. +/// Abandon the batch: the queue empties, every request in flight is cancelled and its answer +/// is dropped. pub(in crate::analytics::tuner) fn stop() { let job = job(); let mut st = lock(job); st.stop = true; st.pending.clear(); st.deferred.clear(); - if let Some(cancel) = &st.in_flight_cancel { - cancel.store(true, Ordering::Relaxed); + for out in &st.in_flight { + out.cancel.store(true, Ordering::Relaxed); } st.notify(JobEvent::Progress); drop(st); @@ -242,6 +365,12 @@ pub(in crate::analytics::tuner) fn attach() -> mpsc::Receiver { rx } +/// Every id the batch knows — queued, waiting, out, or answered since it started — so a view +/// can count what it could still add to a running batch. +pub(in crate::analytics::tuner) fn known_uids() -> HashSet { + lock(job()).known() +} + /// The job as the caption reads it. pub(in crate::analytics::tuner) fn progress() -> Progress { let st = lock(job()); @@ -249,7 +378,11 @@ pub(in crate::analytics::tuner) fn progress() -> Progress { active: st.active(), done: st.done, total: st.total, - in_flight: st.in_flight.clone(), + in_flight: st + .in_flight + .iter() + .map(|f| (f.uids.clone(), f.market.clone())) + .collect(), } } @@ -269,6 +402,119 @@ fn split_by_key(rows: Vec, key: &str, key_of: impl Fn(&T) -> &str) -> (Vec rows.into_iter().partition(|row| key_of(row) == key) } +/// The index of the next row that may go out, while the in-flight ceiling allows one more: the +/// OLDEST pending row (the queue pops from its end) whose exchange key has nothing in flight — +/// among the rows the user is looking at ([`prioritize`]) when any of those is on a free key, +/// else among all. +/// +/// Args: +/// pending: The exchange key and the priority mark of every pending row, in queue order +/// (newest first). +/// busy: The exchange keys with a request out. +/// out: How many requests are out. +/// +/// Returns: +/// The queue index to take, or `None` when nothing may go out now. +pub(super) fn pick_dispatchable<'a>( + pending: impl Iterator, + busy: &HashSet<&str>, + out: usize, +) -> Option { + if out >= MAX_IN_FLIGHT { + return None; + } + let rows: Vec<(&str, bool)> = pending.collect(); + let free = |(key, _): &(&str, bool)| !busy.contains(key); + rows.iter() + .rposition(|row| row.1 && free(row)) + .or_else(|| rows.iter().rposition(free)) +} + +/// What the cluster rule reads of a row: its market on its exchange, and its window. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct ClusterKey<'a> { + pub(super) exchange_key: &'a str, + pub(super) market: &'a str, + pub(super) buy_ms: i64, + pub(super) close_ms: i64, + pub(super) margin_ms: i64, +} + +/// The rows that go out with the seed in one request: every pending row of the seed's market +/// whose margined window overlaps the cluster's hull, taken while the hull's first entry and +/// last exit stay within [`LONG_POSITION_MS`]. Grows until nothing more joins — a row that +/// joins can bridge to the next one. +/// +/// Args: +/// rows: The pending rows' keys, in queue order. +/// seed: The index of the row the dispatcher picked. +/// +/// Returns: +/// The indices of the cluster, the seed included, ascending. +pub(super) fn pick_cluster(rows: &[ClusterKey<'_>], seed: usize) -> Vec { + let anchor = rows[seed]; + let mut taken = vec![seed]; + let (mut first_buy, mut last_close) = (anchor.buy_ms, anchor.close_ms); + loop { + let mut grew = false; + for (index, row) in rows.iter().enumerate() { + if taken.contains(&index) + || row.exchange_key != anchor.exchange_key + || row.market != anchor.market + { + continue; + } + let overlaps = row.buy_ms.saturating_sub(row.margin_ms) + <= last_close.saturating_add(anchor.margin_ms) + && row.close_ms.saturating_add(row.margin_ms) + >= first_buy.saturating_sub(anchor.margin_ms); + let hull_from = first_buy.min(row.buy_ms); + let hull_to = last_close.max(row.close_ms); + if overlaps && hull_to.saturating_sub(hull_from) <= LONG_POSITION_MS { + taken.push(index); + first_buy = hull_from; + last_close = hull_to; + grew = true; + } + } + if !grew { + break; + } + } + taken.sort_unstable(); + taken +} + +/// Whether a row goes back for the rest of its tape: the walk ran (no refusal to wait out — +/// the caller checks that first), left the row uncovered, stopped SHORT of the request's focus +/// on the worker's own budget or deadline, covered more of it than the row's previous walk +/// did — the same answer serves every row of a cluster, so a row the walk has not reached yet +/// still sees the walk advance — and the row has continuations left. A walk that covered the +/// whole focus and still left the row missing found no prints for it, which no continuation +/// changes; one that gained nothing found a stretch the venue serves nothing for. +/// +/// Args: +/// status: The walk's tick status. +/// tape: What the row became after the replay off the tiles. +/// walk_short: Whether the answer's coverage stops short of the request's focus. +/// covered_ms: Width of the answer's coverage. +/// previous_ms: Width after the row's previous walk; zero before the first. +/// continued: Continuations the row already had. +pub(super) fn continues( + status: TickStatus, + tape: TapeStatus, + walk_short: bool, + covered_ms: i64, + previous_ms: i64, + continued: u8, +) -> bool { + tape == TapeStatus::Missing + && matches!(status, TickStatus::Served | TickStatus::Streaming) + && walk_short + && covered_ms > previous_ms + && continued < MAX_CONTINUATIONS +} + /// How long the batch waits before asking for the same row again, when it should: the gate /// refused the host and the row is still uncovered. A row the tiles covered anyway needs no /// second ask, and every other status is the row's final word. @@ -279,65 +525,137 @@ pub(super) fn retry_wait(status: TickStatus, tape: TapeStatus) -> Option { } } -/// The thread: one row at a time, waits included. -fn run(job: &Job) { +/// The thread: dispatches one row per free exchange key onto its own walk, waits included. +/// It never walks itself, so a venue's three-minute walk holds nobody else's turn. +fn run(job: &'static Job) { loop { - let (row, defaults) = { - let mut st = lock(job); - loop { - if st.stop { - st.stop = false; - st.pending.clear(); - st.deferred.clear(); - } - let now = Instant::now(); - if st.resume_due(now) { - st.notify(JobEvent::Progress); - } - if let Some(row) = st.pending.pop() { - break (row, st.defaults.clone()); - } - if st.deferred.is_empty() { - // The batch is over, or none was ever started: sleep until a start. - st.notify(JobEvent::Progress); - st = job - .wake - .wait(st) - .unwrap_or_else(std::sync::PoisonError::into_inner); - } else { - let earliest = st.deferred.iter().map(|d| d.due).min().unwrap_or(now); - let wait = earliest.saturating_duration_since(now); - st = job - .wake - .wait_timeout(st, wait) - .unwrap_or_else(std::sync::PoisonError::into_inner) - .0; - } + let mut st = lock(job); + if st.stop { + st.stop = false; + st.pending.clear(); + st.deferred.clear(); + } + let now = Instant::now(); + if st.resume_due(now) { + st.notify(JobEvent::Progress); + } + if let Some(index) = st.dispatchable() { + let keys: Vec> = st + .pending + .iter() + .map(|row| ClusterKey { + exchange_key: &row.address.exchange_key, + market: &row.address.market, + buy_ms: row.deal.buy_ms, + close_ms: row.deal.close_ms, + margin_ms: row.window.margin_ms, + }) + .collect(); + let indices = pick_cluster(&keys, index); + // Removed from the back, so each index still names the row it was picked for. + let mut rows: Vec = indices + .iter() + .rev() + .map(|&i| st.pending.remove(i)) + .collect(); + rows.reverse(); + let defaults = st.defaults.clone(); + let cancel = Arc::new(AtomicBool::new(false)); + let uids: Vec = rows.iter().map(|r| r.deal.report_uid).collect(); + let first = &rows[0]; + st.in_flight.push(InFlight { + uids: uids.clone(), + market: match rows.len() { + 1 => first.address.market.clone(), + n => format!("{}×{n}", first.address.market), + }, + exchange_key: first.address.exchange_key.clone(), + cancel: cancel.clone(), + }); + for &uid in &uids { + st.notify(JobEvent::Started(uid)); } - }; - serve_one(job, row, &defaults); + drop(st); + // A thread per walk rather than a lane per venue: the walk blocks on the worker's + // reply for up to its trade deadline, and the number of them is bounded by the + // exchange keys and `MAX_IN_FLIGHT`, never by the batch. + let spawned = std::thread::Builder::new() + .name("tuner-ticks-walk".into()) + .spawn(move || serve_cluster(job, rows, cancel, &defaults)); + if let Err(error) = spawned { + // No thread, no walk: the rows went with the closure. Counted as done so the + // batch's total still balances, and said once. + log::warn!( + "[x] ticks fetch: no thread for {} row(s): {error}", + uids.len() + ); + let mut st = lock(job); + st.in_flight.retain(|f| f.uids != uids); + st.done += uids.len(); + st.notify(JobEvent::Progress); + } + continue; + } + if st.deferred.is_empty() && st.pending.is_empty() { + // Nothing queued and nothing waiting: the batch is over once the walks out come + // back, or none was ever started. Sleep until a start, an enqueue, or a walk's end. + if st.in_flight.is_empty() { + st.notify(JobEvent::Progress); + } + drop( + job.wake + .wait(st) + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + } else { + // Rows queued but every one of them is on a busy key, or rows waiting out a + // venue's backoff: wake at the earliest due time, or when a walk ends. + let earliest = st.deferred.iter().map(|d| d.due).min().unwrap_or(now); + let wait = match st.deferred.is_empty() { + true => Duration::from_secs(60), + false => earliest.saturating_duration_since(now), + }; + drop( + job.wake + .wait_timeout(st, wait) + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + } } } -/// Ask for one row, wait for the worker, replay the row off the tiles, and file the answer. -fn serve_one(job: &Job, mut row: QueuedRow, defaults: &HashMap) { - let uid = row.deal.report_uid; - let cancel = Arc::new(AtomicBool::new(false)); +/// Ask for one cluster of rows — one request over the hull of their windows — wait for the +/// worker, replay every row off the tiles, and file each answer. Runs on its own thread. +fn serve_cluster( + job: &'static Job, + rows: Vec, + cancel: Arc, + defaults: &HashMap, +) { + let uids: Vec = rows.iter().map(|r| r.deal.report_uid).collect(); + let first = &rows[0]; + // The hull: the first entry to the last exit, with the seed's margin — what + // `pick_cluster` kept within an hour, so the worker walks it as one stretch. + let first_buy = rows + .iter() + .map(|r| r.deal.buy_ms) + .min() + .unwrap_or(first.deal.buy_ms); + let last_close = rows + .iter() + .map(|r| r.deal.close_ms) + .max() + .unwrap_or(first.deal.close_ms); + let window = + replay_window_ms(first_buy, last_close, first.window.margin_ms).unwrap_or(first.window); let (reply, rx) = mpsc::channel(); - let progress = { - let mut st = lock(job); - st.in_flight = Some((uid, row.address.market.clone())); - st.in_flight_cancel = Some(cancel.clone()); - st.notify(JobEvent::Started(uid)); - (st.done + 1, st.total) - }; let started = Instant::now(); worker::request(TradeReplayRequest { - address: row.replay_address.clone(), - market: row.address.market.clone(), - window: row.window, - identity: fetch_identity(uid), - tick_value: row.tick_value, + address: first.replay_address.clone(), + market: first.address.market.clone(), + window, + identity: fetch_identity(uids[0]), + tick_value: first.tick_value, ticks: true, intent: ReplayIntent::Model, cancel: cancel.clone(), @@ -347,11 +665,18 @@ fn serve_one(job: &Job, mut row: QueuedRow, defaults: &HashMap) { // last outcome before the drop is the tick stage's word. A model's request arms no archive // follow-up, so the drop comes right after the stage. let mut status = TickStatus::Failed; + // What the walk's answer covers of the request's focus — the tiles it found plus what it + // fetched. Short of the focus, the walk stopped on its own budget or deadline, and the + // rows it did not reach may be worth a continuation; wider than the last answer, it gained. + let mut walk_covered = Coverage::none(); let mut outcomes = 0usize; while let Ok(outcome) = rx.recv() { outcomes += 1; status = match outcome { - TradeReplayOutcome::Ready(series) => series.tick_status, + TradeReplayOutcome::Ready(series) => { + walk_covered = series.covered.clone(); + series.tick_status + } // The candle stage refused by the gate: the same wait as a refused walk. TradeReplayOutcome::Failed(TradeReplayFailure::RateLimited { retry_in_s }) => { TickStatus::RateLimited { retry_in_s } @@ -368,71 +693,108 @@ fn serve_one(job: &Job, mut row: QueuedRow, defaults: &HashMap) { if cancel.load(Ordering::Relaxed) { // Stopped mid-walk: nothing to file, the queue is already empty. let mut st = lock(job); - st.in_flight = None; - st.in_flight_cancel = None; + st.in_flight.retain(|f| f.uids != uids); st.notify(JobEvent::Progress); + drop(st); + job.wake.notify_all(); return; } - let lines = archived_lines_of(&row.deal); - let mut answer = DealRow { - deal: row.deal.clone(), - tape: TapeStatus::Missing, - verdict: None, - address: Some(row.address.clone()), - ticks: None, - entry_start: None, - }; - replay_row(&mut answer, defaults, lines); - let mut wait = retry_wait(status, answer.tape).map(|s| Duration::from_secs(u64::from(s))); - // The venue itself refused mid-walk — the row that put its host into the backoff. Once - // more, after the wait the gate will name for the rows behind it; the second time is final. - if wait.is_none() - && answer.tape == TapeStatus::Missing - && status == TickStatus::Failed - && !row.retried - { - row.retried = true; - wait = Some(VENUE_REFUSAL_WAIT); - } - if answer.tape == TapeStatus::Missing && wait.is_none() { - answer.tape = match status { - TickStatus::Served | TickStatus::Pending | TickStatus::Streaming => TapeStatus::Missing, - refused => TapeStatus::Refused(refused), + let cluster = rows.len(); + let market = first.address.market.clone(); + let (from_ms, to_ms) = (window.from_ms, window.to_ms); + let walk_short = !walk_covered.covers(&window.focus_spans()); + let covered_ms = walk_covered.width_ms(); + // Every row on its own off the tiles: the walk's one answer says how the venue behaved, + // the coverage says which rows it reached. + for (position, mut row) in rows.into_iter().enumerate() { + let uid = row.deal.report_uid; + let replayed_at = Instant::now(); + let lines = archived_lines_of(&row.deal); + let mut answer = DealRow { + deal: row.deal.clone(), + tape: TapeStatus::Missing, + verdict: None, + address: Some(row.address.clone()), + ticks: None, + entry_start: None, }; - } - // One line per row, so a batch that looks stuck can be read instead of guessed: what the - // worker answered, how long it took, and what the row became. The binary logs at `warn` by - // default; this target is the one the base filter raises for exactly these lines. - log::info!( - target: moon_core::diagnostics::TICKS_AXIS_TARGET, - "[x] ticks fetch {}/{} {} uid={} window={}..{}: {status:?} after {outcomes} outcome(s) in {} ms, replayed in {} ms -> {}", - progress.0, - progress.1, - row.address.market, - uid, - row.window.from_ms, - row.window.to_ms, - answered.as_millis(), - started.elapsed().saturating_sub(answered).as_millis(), - match wait { - Some(wait) => format!("retry in {} s", wait.as_secs()), - None => format!("{:?}", answer.tape), + replay_row(&mut answer, defaults, lines); + let mut wait = retry_wait(status, answer.tape).map(|s| Duration::from_secs(u64::from(s))); + // Back to the end of the venue's turn, for the next walk to continue from where the + // tiles end — see [`continues`]; the ceiling, no gain, or any other word is final. + let continue_walk = wait.is_none() + && continues( + status, + answer.tape, + walk_short, + covered_ms, + row.covered_ms, + row.continued, + ); + if continue_walk { + row.continued += 1; + row.covered_ms = covered_ms; } - ); - let mut st = lock(job); - st.in_flight = None; - st.in_flight_cancel = None; - match wait { - Some(wait) if !st.stop => { - st.defer(row, wait); - st.notify(JobEvent::Row(Box::new(answer))); + // The venue itself refused mid-walk — the row that put its host into the backoff. + // Once more, after the wait the gate will name for the rows behind it; the second + // time is final. + if wait.is_none() + && answer.tape == TapeStatus::Missing + && status == TickStatus::Failed + && !row.retried + { + row.retried = true; + wait = Some(VENUE_REFUSAL_WAIT); + } + if answer.tape == TapeStatus::Missing && wait.is_none() && !continue_walk { + answer.tape = match status { + TickStatus::Served | TickStatus::Pending | TickStatus::Streaming => { + TapeStatus::Missing + } + refused => TapeStatus::Refused(refused), + }; } - _ => { - st.done += 1; - st.finished.push((uid, Instant::now())); - st.notify(JobEvent::Row(Box::new(answer))); + let replayed_ms = replayed_at.elapsed().as_millis(); + let outcome_text = match (wait, continue_walk) { + (Some(wait), _) => format!("retry in {} s", wait.as_secs()), + (None, true) => format!( + "continue {}/{MAX_CONTINUATIONS}, {covered_ms} ms of the focus covered", + row.continued + ), + (None, false) => format!("{:?}", answer.tape), + }; + let mut st = lock(job); + // The request is out until its last row is filed; the key stays busy meanwhile. + if position + 1 == cluster { + st.in_flight.retain(|f| f.uids != uids); } + match wait { + Some(wait) if !st.stop => st.defer(row, wait), + // The FRONT of the queue is the last to go: the venue's other rows first. + None if continue_walk && !st.stop => st.pending.insert(0, row), + _ => { + st.done += 1; + st.finished.push((uid, Instant::now())); + } + } + // One line per row, so a batch that looks stuck can be read instead of guessed: what + // the worker answered for the cluster, how long the walk took, and what the row + // became. The count is the batch's as of THIS answer — walks run in parallel, so a + // number taken at dispatch would repeat. The binary logs at `warn` by default; this + // target is the one the base filter raises for exactly these lines. + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks fetch {}/{} {market} uid={uid} cluster={}/{cluster} window={from_ms}..{to_ms}: {status:?} after {outcomes} outcome(s) in {} ms, replayed in {replayed_ms} ms -> {outcome_text}", + st.done, + st.total, + position + 1, + answered.as_millis(), + ); + st.notify(JobEvent::Row(Box::new(answer))); + drop(st); } + // The key is free again, or the batch is over: the dispatcher decides which. + job.wake.notify_all(); } /// A replay identity for a fetch, distinct from every chart window's: the row's own id, which diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs index c6d532db..bffaa150 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs @@ -1,8 +1,13 @@ // Explicit imports, never `use super::*`: the crate's views import `gpui::*`, whose own // `test` shadows the built-in attribute and makes `#[test]` expand recursively. +use std::collections::HashSet; + use super::super::super::state::TapeStatus; -use super::{retry_wait, split_by_key}; -use moon_core::market::trade_replay::TickStatus; +use super::{ + ClusterKey, MAX_CONTINUATIONS, MAX_IN_FLIGHT, continues, pick_cluster, pick_dispatchable, + retry_wait, split_by_key, +}; +use moon_core::market::trade_replay::{LONG_POSITION_MS, TickStatus}; /// Only a gate refusal on a still-uncovered row is asked again, with the gate's own number; /// a row the tiles covered anyway, and every other status, is final here (a venue's own @@ -17,6 +22,158 @@ fn only_a_refused_uncovered_row_waits_the_gate_out() { assert_eq!(retry_wait(TickStatus::Served, TapeStatus::Missing), None); } +/// One request per exchange key at a time, the oldest row first, and none past the ceiling: +/// the dispatcher skips the rows of a busy key for the next key's oldest row. A row the user is +/// looking at goes before its venue's older rows, but never onto a busy venue. +#[test] +fn the_dispatcher_takes_the_oldest_row_of_a_free_key_marked_rows_first() { + // Queue order is newest-first; the end is the oldest. + let keys = ["gate", "binance", "okx", "binance"]; + let plain = + |busy: &HashSet<&str>, out| pick_dispatchable(keys.iter().map(|k| (*k, false)), busy, out); + let none: HashSet<&str> = HashSet::new(); + assert_eq!(plain(&none, 0), Some(3), "nothing out: the oldest row goes"); + let binance_busy: HashSet<&str> = ["binance"].into_iter().collect(); + assert_eq!( + plain(&binance_busy, 1), + Some(2), + "binance out: okx's oldest goes, binance's second row waits" + ); + let all_busy: HashSet<&str> = ["binance", "okx", "gate"].into_iter().collect(); + assert_eq!(plain(&all_busy, 3), None); + assert_eq!( + plain(&none, MAX_IN_FLIGHT), + None, + "the ceiling holds whatever the keys" + ); + // The newest binance row (index 1) and the gate row (index 0) are what the user sees. + let marked = [ + ("gate", true), + ("binance", true), + ("okx", false), + ("binance", false), + ]; + assert_eq!( + pick_dispatchable(marked.iter().copied(), &none, 0), + Some(1), + "the oldest MARKED row goes before the venue's older unmarked one" + ); + assert_eq!( + pick_dispatchable(marked.iter().copied(), &binance_busy, 1), + Some(0), + "binance busy: the marked gate row, not the unmarked okx one" + ); + let gate_and_binance_busy: HashSet<&str> = ["binance", "gate"].into_iter().collect(); + assert_eq!( + pick_dispatchable(marked.iter().copied(), &gate_and_binance_busy, 2), + Some(2), + "no marked row on a free venue: the oldest unmarked one goes" + ); +} + +/// A row goes back for more tape only when the walk ran, stopped short of the focus, gained +/// on the previous walk, left the row missing, and has continuations left; a complete walk, +/// a walk without gain, a covered row, a refusal and the ceiling all end it. +#[test] +fn a_row_continues_while_a_short_walk_gains_tape() { + let served = TickStatus::Served; + assert!(continues(served, TapeStatus::Missing, true, 60_000, 0, 0)); + assert!(continues( + served, + TapeStatus::Missing, + true, + 120_000, + 60_000, + 3 + )); + assert!(continues( + TickStatus::Streaming, + TapeStatus::Missing, + true, + 1, + 0, + 0 + )); + assert!( + !continues(served, TapeStatus::Missing, false, 60_000, 0, 0), + "a walk that reached the whole focus has nothing more to fetch" + ); + assert!( + !continues(served, TapeStatus::Missing, true, 60_000, 60_000, 1), + "no gain: the venue serves nothing for the stretch" + ); + assert!( + !continues(served, TapeStatus::Covered, true, 60_000, 0, 0), + "a covered row is done whatever the walk did" + ); + assert!( + !continues( + served, + TapeStatus::Missing, + true, + 60_000, + 0, + MAX_CONTINUATIONS + ), + "the ceiling" + ); + for status in [ + TickStatus::Failed, + TickStatus::NoRoute, + TickStatus::NoTrades, + TickStatus::RateLimited { retry_in_s: 30 }, + TickStatus::OutOfRetention { retention_ms: 1 }, + ] { + assert!( + !continues(status, TapeStatus::Missing, true, 60_000, 0, 0), + "{status:?} is the venue's word, not a budget stop" + ); + } +} + +/// A cluster is the seed plus every row of the same market whose margined window overlaps the +/// hull — through a bridge row — never another market or exchange, and never past an hour +/// from the first entry to the last exit. +#[test] +fn a_cluster_takes_the_overlapping_rows_of_one_market_within_an_hour() { + const MIN: i64 = 60_000; + let key = |exchange_key, market, buy_ms, close_ms| ClusterKey { + exchange_key, + market, + buy_ms, + close_ms, + margin_ms: 5 * MIN, + }; + let base = 1_000_000 * MIN; + let rows = [ + // 0: another market, same minute — never joins. + key("binance", "BTCUSDT", base, base + MIN), + // 1: the seed's market, 8 min after the seed's close: joins through the margins. + key("binance", "AKEUSDT", base + 10 * MIN, base + 11 * MIN), + // 2: joins only through row 1 (19 min after the seed, 8 after row 1). + key("binance", "AKEUSDT", base + 19 * MIN, base + 20 * MIN), + // 3: the seed. + key("binance", "AKEUSDT", base, base + 2 * MIN), + // 4: same market, but 40 min after row 2 — no overlap, stays. + key("binance", "AKEUSDT", base + 60 * MIN, base + 61 * MIN), + // 5: same market name on another exchange — never joins. + key("gate", "AKEUSDT", base, base + MIN), + ]; + assert_eq!(pick_cluster(&rows, 3), vec![1, 2, 3]); + assert_eq!( + pick_cluster(&rows, 0), + vec![0], + "a lone row is its own cluster" + ); + // Overlapping rows past the hour from the first entry: the hull stops growing. + let long = [ + key("okx", "ONE-USDT-SWAP", base, base + 50 * MIN), + key("okx", "ONE-USDT-SWAP", base + 52 * MIN, base + 70 * MIN), + ]; + assert!(70 * MIN > LONG_POSITION_MS); + assert_eq!(pick_cluster(&long, 0), vec![0]); +} + /// A deferral takes every queued row of the refused venue, in queue order, and leaves the /// other venues' rows in theirs. #[test] diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 3a8fe63e..3741aa94 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -6,9 +6,10 @@ //! market, which only the live market source knows — and publishes the rows at once, without //! their tape (stage B). Stage C then asks the replay worker for the held tape of every row in //! ONE batch of queries, reads the archived entry lines, runs the model on the parameters as -//! of the buy, and folds the answers into the published rows. In one batch, because the worker -//! is one thread that serves held queries only between its walks: asked one at a time, a -//! thousand rows would each wait for a walk of the fetch batch that may be running. +//! of the buy, and folds the answers into the published rows. In one batch because it is one +//! round trip for the table: the worker's coordinator answers held queries off no venue call, +//! so each costs a lock and a disk read, and a thousand of them asked together come back in +//! the time of one. //! //! This file only ever WRITES `TicksState`; the rendering only reads it. @@ -32,16 +33,15 @@ use moon_core::db::tuner::{VarStats, Variant, strategy_current_values, strategy_ use moon_core::feed::report_traces::ArchivedLineKind; use moon_core::feed::types::Tick; use moon_core::market::trade_replay::{ - Coverage, TickQuery, margin_ms, query_held, replay_window_ms, + Coverage, TickQuery, model_margin_ms, query_held, replay_window_ms, }; -/// How long a held query waits for the worker's answer. The worker serves a held query -/// between its jobs, and a walk of the fetch batch can hold it for up to its trade deadline -/// plus a candle stage, with chart windows' own stages queued ahead; past this the rows still -/// unanswered fold as missing, and the log says how many. +/// How long a held query waits for the worker's answer. The coordinator answers held queries +/// off no venue call, so the wait is normally milliseconds; the ceiling is for a disk that +/// stalls — past it the rows still unanswered fold as missing, and the log says how many. const HELD_ANSWER_WAIT: Duration = Duration::from_secs(240); -/// What stage A brings back. +/// What stage A brings back: the deals, the "Fact" KPI and the grid's "now" values. type StageA = ( Result, Result, ReadFail>, @@ -97,12 +97,15 @@ impl AnalyticsView { let deals = moon_core::db::tuner::ticks::read_deals(&q); // One line per load, so "no deals" can be read against the scope that was // actually asked — period, strategies — instead of guessed from the panel. + // `q.strategies` is empty for "every strategy" (0 targets). match &deals { Ok(read) => log::info!( target: moon_core::diagnostics::TICKS_AXIS_TARGET, - "[x] ticks load: {} deal(s) with ms stamps, {} without, period {}..{}, {} strategy target(s)", + "[x] ticks load: {} deal(s) with ms stamps, {} without, {} service, {} not tunable, period {}..{}, {} strategy target(s)", read.deals.len(), read.without_ms, + read.service, + read.untunable, q.from, q.to, q.strategies.len() @@ -156,45 +159,19 @@ impl AnalyticsView { ); } - /// Where each deal's prints live, per distinct `(core, coin)`: the core's exchange key and - /// the catalog-verified market. A core that is not connected, or a coin its catalog does + /// Where each deal's prints live, per distinct `(core, coin)` — the fetch's own resolver, + /// asked once per distinct pair. A core that is not connected, or a coin its catalog does /// not spell, resolves to nothing and the row says so. fn resolve_addresses( &self, deals: &[Deal], cx: &Context, ) -> HashMap<(u64, String), Option>> { - let backend = self.backend.read(cx); - let source = backend.session.market_source(); - let mut out: HashMap<(u64, String), Option>> = HashMap::new(); - for deal in deals { - let key = (deal.core_uid, deal.coin.clone()); - if out.contains_key(&key) { - continue; - } - let quote = backend - .config - .servers - .iter() - .find(|s| s.id == deal.core_uid) - .map(|s| s.market.as_str()) - .unwrap_or_default(); - let address = source - .replay_address(deal.core_uid) - .ok() - .and_then(|address| { - let market = source.resolve_market(deal.core_uid, quote, &deal.coin)?; - let tick = source.price_step(deal.core_uid, &market); - Some(Arc::new(RowAddress { - core_uid: deal.core_uid, - exchange_key: address.exchange_key, - market, - tick, - })) - }); - out.insert(key, address); - } - out + let mut resolver = super::fetch::FetchResolver::of(&self.backend.read(cx)); + deals + .iter() + .map(|deal| ((deal.core_uid, deal.coin.clone()), resolver.address(deal))) + .collect() } /// Stage B: the rows, published at once without their tape; stage C follows. @@ -243,6 +220,8 @@ impl AnalyticsView { let mut data = TicksData { rows, without_ms: read.without_ms, + service: read.service, + untunable: read.untunable, kpi: fact, entry_share: (0, 0), exit_share: (0, 0), @@ -348,6 +327,9 @@ impl AnalyticsView { this.ticks.update_rows(rows); // The row the fetch job is out for says so again after the fold. this.mark_fetch_in_flight(); + // The rows still missing are what the user is looking at: a running batch + // takes them next. + this.ticks_prioritize_visible(); // The replayable set may have changed under the variant columns: rescore them. this.arm_ticks_variants(cx); cx.notify(); @@ -403,7 +385,7 @@ fn ask_held( mpsc::Receiver, Coverage, )> { - let window = replay_window_ms(deal.buy_ms, deal.close_ms, margin_ms())?; + let window = replay_window_ms(deal.buy_ms, deal.close_ms, model_margin_ms())?; let spans = window.focus_spans(); let (reply, rx) = mpsc::channel(); query_held(TickQuery { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index da543d9e..f9365c6a 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -26,11 +26,12 @@ use super::{sort_arrow_of, toggle_sort_key}; use crate::design; use crate::design::{moon, moon_alpha}; use columns::*; +pub(in crate::analytics::tuner) use fetch::strategy_field_defaults; use state::SuggState; use state::{DealRow, TapeStatus}; pub(in crate::analytics::tuner) mod columns; -mod fetch; +pub(crate) mod fetch; mod grid; mod load; pub(in crate::analytics::tuner) mod rows; @@ -51,6 +52,15 @@ impl AnalyticsView { // The order is settled before the data is viewed: both live in `ticks`, and the sort // cache needs the mutable half. let drawn = rows::order_for(&mut self.ticks).len(); + // The rows the scope holds but the table does not show, for the caption and for the + // empty state: a period entirely before the millisecond stamps is not an empty period, + // and the table must say which it is rather than draw the shared "no trades". + let left_out = self + .ticks + .data + .data() + .map(|d| (d.without_ms, d.service, d.untunable)) + .unwrap_or_default(); let summary = self.ticks.data.view(|d| d.rows.is_empty()).map(|d| { ( d.rows.len(), @@ -60,6 +70,24 @@ impl AnalyticsView { ) }); let (body, total, covered, fetchable, without_ms) = match summary { + Err(crate::load_state::Note::Empty) if left_out != (0, 0, 0) => ( + crate::load_state::muted( + t!( + "analytics.ticks.empty_left_out", + without = left_out.0, + service = left_out.1, + untunable = left_out.2 + ) + .to_string(), + 10.0, + p, + cx, + ), + 0usize, + 0usize, + 0usize, + left_out.0, + ), Err(note) => ( super::super::note_el("an-ticks-note", note, 10.0, p, cx), 0usize, @@ -99,16 +127,33 @@ impl AnalyticsView { // flight at all; a bare "N/M" reads as stuck in both cases. let progress = fetch::job::progress(); let fetch_active = progress.active; + // Rows of THIS table a running batch does not have — the autoload's, or one left by a + // previous window: a second button adds them, while the first stays the stop. + let addable = self.ticks_fetch_addable(); + // A batch this window did not start — the startup autoload, or one left by a previous + // window — is listened to from the first paint that finds it running, so its answers + // land in the table and the button keeps counting. Idempotent: one listener per view + // while a batch runs. + if fetch_active { + self.attach_fetch_listener(cx); + } let fetch_label = if !fetch_active && self.ticks.tape_reading { t!("analytics.ticks.fetch_reading").to_string() } else if !fetch_active { t!("analytics.ticks.fetch_btn").to_string() - } else if let Some((_, market)) = progress.in_flight { + } else if !progress.in_flight.is_empty() { + // Every market a request is out for, in the order they went out: the walks run in + // parallel across venues, and one name would read as one request. + let markets: Vec<&str> = progress + .in_flight + .iter() + .map(|(_, market)| market.as_str()) + .collect(); t!( "analytics.ticks.fetch_progress_at", done = progress.done, total = progress.total, - market = market + market = markets.join(" · ") ) .to_string() } else { @@ -153,23 +198,32 @@ impl AnalyticsView { .text_color(moon(p.text_muted)) .child(scope), ) - // "N with tape of M · K without stamps": the honest size of the sample. + // "N with tape of M · K without stamps": the honest size of the sample, + // with the service rows and the switch's leftovers when there are any. .child( div() .flex_none() .font_family(design::ui_font()) .text_size(design::t_caption(cx)) .text_color(moon(p.text_muted)) - .child( - t!( - "analytics.ticks.coverage", - covered = covered, - total = total, - without = without_ms - ) - .to_string(), - ), + .child(coverage_caption( + covered, total, without_ms, left_out.1, left_out.2, + )), ) + .when(fetch_active && addable > 0, |el| { + el.child( + div().font_family(design::ui_font()).child( + MoonButton::new("an-ticks-fetch-add") + .variant(MoonButtonVariant::Soft) + .label(t!("analytics.ticks.fetch_add", n = addable).to_string()) + .on_click(cx.listener(move |this, _, _, cx| { + this.ticks_fetch_missing(cx); + cx.notify(); + })) + .render(), + ), + ) + }) .when(fetchable > 0 || fetch_active, |el| { el.child( div().font_family(design::ui_font()).child( @@ -557,6 +611,68 @@ fn tape_mark(tape: TapeStatus) -> (&'static str, String) { } } +/// The tape dot of a row: filled in the state's colour, hollow while the tape is missing — +/// the same distinction the column's ●/○ draws, readable at any table width. +fn tape_dot( + tape: TapeStatus, + tip: String, + report_uid: i64, + p: MoonPalette, + scale: f32, +) -> AnyElement { + let (color, filled) = match tape { + TapeStatus::Covered => (p.green, true), + TapeStatus::Fetching => (p.amber, true), + TapeStatus::Refused(_) => (p.red, true), + TapeStatus::NoAddress => (p.text_muted, true), + TapeStatus::Missing => (p.text_muted, false), + }; + let size = px(TAPE_DOT_PX * scale); + let dot = div().size(size).rounded_full().flex_none(); + let dot = if filled { + dot.bg(moon(color)) + } else { + dot.border_1().border_color(moon(color)) + }; + div() + .id(SharedString::from(format!("an-ticks-dot-{report_uid}"))) + .flex_none() + .child(dot) + .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(tip.clone())).into()) + .into_any_element() +} + +/// Diameter of the row's tape dot, in base px, before the font scale. +const TAPE_DOT_PX: f32 = 7.0; + +/// The header caption of the table: how many rows have their tape, out of how many, and what +/// the scope holds beyond the table — rows without millisecond stamps always, the service rows +/// and the untunable ones only when there are any. +fn coverage_caption( + covered: usize, + total: usize, + without_ms: usize, + service: usize, + untunable: usize, +) -> String { + let mut caption = t!( + "analytics.ticks.coverage", + covered = covered, + total = total, + without = without_ms + ) + .to_string(); + if service > 0 { + caption.push_str(" · "); + caption.push_str(&t!("analytics.ticks.coverage_service", n = service)); + } + if untunable > 0 { + caption.push_str(" · "); + caption.push_str(&t!("analytics.ticks.coverage_untunable", n = untunable)); + } + caption +} + /// The mark of the model column — one glyph per group — and its tooltip with the deviations. fn model_mark(row: &DealRow) -> (String, String) { let Some(v) = row.verdict else { @@ -631,6 +747,10 @@ fn deal_row( .bg(moon(p.table_body)) .border_t_1() .border_color(moon_alpha(p.border, 0.5)) + // The tape's state as a dot at the LEFT edge, before the coin: the tape column sits + // last and is the first thing a narrow table cuts off, and whether a row has its tape + // is the one thing about it this axis is for. Same tooltip as the column's mark. + .child(tape_dot(row.tape, tape_tip.clone(), d.report_uid, p, scale)) .child( div() .flex_1() diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 83eebabc..fc30f6b0 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -91,6 +91,12 @@ pub(in crate::analytics::tuner) struct TicksData { pub(in crate::analytics::tuner) rows: Vec, /// Scope rows without millisecond stamps — in the Fact column, not in the table. pub(in crate::analytics::tuner) without_ms: usize, + /// Service rows with stamps the axis never takes (funding, liquidations, joined sells, no + /// strategy) — in the Fact column, not in the table. + pub(in crate::analytics::tuner) service: usize, + /// Trades the tuner cannot be run on — container or unresolved kinds, manual exits — in + /// the Fact column, not in the table. + pub(in crate::analytics::tuner) untunable: usize, /// Column 0: the whole scope (the same SQL as every axis' "Fact", stamps or not); column /// 1: the rows the tape covers. pub(in crate::analytics::tuner) kpi: Vec, diff --git a/crates/moon-ui-gpui/src/load_state.rs b/crates/moon-ui-gpui/src/load_state.rs index 84d88bb7..125d5a77 100644 --- a/crates/moon-ui-gpui/src/load_state.rs +++ b/crates/moon-ui-gpui/src/load_state.rs @@ -516,7 +516,9 @@ fn fail_detail_row(label: String, value: SharedString, p: MoonPalette, cx: &App) } /// Render a non-error placeholder using the shared muted body style. -fn muted(text: String, pad: f32, p: MoonPalette, cx: &App) -> AnyElement { +/// A muted one-line placeholder — the shape every `Note` that is not a failure renders as, +/// for a panel that words its own empty state. +pub(crate) fn muted(text: String, pad: f32, p: MoonPalette, cx: &App) -> AnyElement { div() .p(design::ui_px(cx, pad)) .text_color(moon(p.text_muted)) diff --git a/crates/moon-ui-gpui/src/settings/storage.rs b/crates/moon-ui-gpui/src/settings/storage.rs index 59908714..cec25cb4 100644 --- a/crates/moon-ui-gpui/src/settings/storage.rs +++ b/crates/moon-ui-gpui/src/settings/storage.rs @@ -266,9 +266,8 @@ impl SettingsView { let limit = self.storage.cfg.strategies.version_limit; let persist_trades = self.storage.cfg.trade_replay.persist_trades; let trades_max_mb = self.storage.cfg.trade_replay.max_mb; - let trades_margin_s = self.storage.cfg.trade_replay.margin_s; - let long_position_min = self.storage.cfg.trade_replay.long_position_min; - let cleanup_at_startup = self.storage.cfg.trade_replay.cleanup_at_startup; + let trades_margin_min = self.storage.cfg.trade_replay.margin_min; + let autoload_missing = self.storage.cfg.trade_replay.autoload_missing; let size_line = |sz: Option<(u64, u64)>| -> String { match sz { @@ -520,6 +519,23 @@ impl SettingsView { )), ) .child(hint(t!("storage.trades_margin_hint").to_string())) + // The Entry/Exit axis' tape autoload: a live cell in moon-core, read by the + // coordination tick, so a flip needs no restart. + .child( + moon_ui::MoonCheckbox::new("trades-autoload-missing") + .checked(autoload_missing) + .label(t!("storage.trades_autoload").to_string()) + .description(t!("storage.trades_autoload_hint").to_string()) + .on_change(cx.listener(|this, v: &bool, _, cx| { + let v = *v; + if this.storage.cfg.trade_replay.autoload_missing != v { + this.storage.cfg.trade_replay.autoload_missing = v; + moon_core::market::trade_replay::set_tape_autoload(v); + storage_cfg::save(&this.storage.cfg); + cx.notify(); + } + })), + ) .child( h_flex() .flex_wrap() diff --git a/crates/moon-ui-gpui/src/startup/boot.rs b/crates/moon-ui-gpui/src/startup/boot.rs index 65bdf113..72409d5d 100644 --- a/crates/moon-ui-gpui/src/startup/boot.rs +++ b/crates/moon-ui-gpui/src/startup/boot.rs @@ -709,9 +709,9 @@ pub(super) fn boot(cfg: AppConfig, input: BootInput, cx: &mut App) { } } b.tick_telegram(cx); - // The startup cleanup of the trade tape: a switch read and a clock compare on - // every tick, a background pass when one is due. - crate::settings::trades_cleanup_startup::tick(b, cx); + // The tape autoload of the tuner's Entry/Exit axis: a switch read and a + // clock compare on every tick, a background pass when one is due. + crate::analytics::tape_autoload::tick(b, cx); // A core removed from the session cannot answer what was asked of it; the // drain edge does not fire for a removal, so the slow tick settles those. // Nothing to walk while no ask is out. diff --git a/locales/analytics.yml b/locales/analytics.yml index 3e17306f..b5c21357 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1712,10 +1712,26 @@ analytics.ticks.coverage: ru: "с лентой %{covered} из %{total} · без мс-штампа %{without}" en: "tape for %{covered} of %{total} · no ms stamp %{without}" es: "cinta en %{covered} de %{total} · sin marca ms %{without}" +analytics.ticks.coverage_service: + ru: "служебных %{n}" + en: "service rows %{n}" + es: "filas de servicio %{n}" +analytics.ticks.coverage_untunable: + ru: "вне тюнинга %{n}" + en: "outside tuning %{n}" + es: "fuera del ajuste %{n}" +analytics.ticks.empty_left_out: + ru: "В периоде нет сделок, по которым ось может считать: без мс-штампа %{without} (старые строки реплики или ядро до штампов — лента по ним не восстанавливается), служебных %{service} (фандинг, ликвидации, объединённые продажи, без стратегии), вне тюнинга %{untunable} (ручные продажи, стратегии без торгового правила)." + en: "The period holds no trade the axis can read: %{without} without millisecond stamps (older replica rows, or a core before the stamps — no tape can be tied to them), %{service} service rows (funding, liquidations, joined sells, no strategy), %{untunable} outside tuning (manual sells, strategies without a trading rule)." + es: "El periodo no tiene operaciones que el eje pueda leer: %{without} sin marcas de milisegundos (filas antiguas de la réplica o un núcleo anterior a las marcas — no hay cinta que atar), %{service} filas de servicio (funding, liquidaciones, ventas unidas, sin estrategia), %{untunable} fuera del ajuste (ventas manuales, estrategias sin regla de trading)." analytics.ticks.fetch_btn: ru: "Прогрузить трейды" en: "Fetch trades" es: "Cargar trades" +analytics.ticks.fetch_add: + ru: "+ ещё %{n}" + en: "+ %{n} more" + es: "+ %{n} más" analytics.ticks.fetch_reading: ru: "лента читается…" en: "reading the tape…" diff --git a/locales/storage.yml b/locales/storage.yml index 387be464..0146e975 100644 --- a/locales/storage.yml +++ b/locales/storage.yml @@ -129,57 +129,17 @@ storage.trades_min: en: "%{min} min" es: "%{min} min" storage.trades_margin_hint: - ru: "С каждого края сделки; у долгой — вокруг входа и вокруг выхода, середина свечами. Не меньше 30 с — столько нужно тюнеру." - en: "At each end of a trade; on a long one, around the entry and around the exit, candles between. At least 30 s — what the tuner needs." - es: "En cada extremo de una posición; en una larga, alrededor de la entrada y de la salida, velas entre ambas. Al menos 30 s: lo que necesita el afinador." -storage.trades_long_position: - ru: "Долгая сделка от" - en: "Long trade from" - es: "Posición larga desde" -storage.trades_long_position_hint: - ru: "Дольше — трейды только вокруг входа и выхода." - en: "Held longer — prints around the entry and the exit only." - es: "Más larga — operaciones solo alrededor de la entrada y la salida." -storage.trades_cleanup_at_startup: - ru: "Чистка при старте" - en: "Clean up at startup" - es: "Limpiar al arrancar" -storage.trades_cleanup_at_startup_hint: - ru: "То же, что кнопка «Чистка». Со следующего запуска." - en: "Same as the \"Clean up\" button. From the next launch." - es: "Lo mismo que el botón «Limpiar». Desde el próximo arranque." -storage.trades_cleanup: - ru: "Чистка" - en: "Clean up" - es: "Limpiar" -storage.trades_cleanup_hint: - ru: "Оставляет трейды сделок стратегий, закрытых самой стратегией (что читает тюнер), в пределах ступени «Трейды вокруг сделки». Ручные продажи, фандинг, ликвидации и трейды без сделки в отчёте уходят. Файл сжимается сразу." - en: "Keeps the prints of strategy trades the strategy closed itself (what the tuner reads), within the \"Prints around a trade\" step. Manual sells, funding, liquidations and prints with no trade in the report go. The file is compacted at once." - es: "Conserva las operaciones de posiciones de estrategias cerradas por la propia estrategia (lo que lee el afinador), dentro del paso «Operaciones alrededor de una posición». Ventas manuales, funding, liquidaciones y operaciones sin posición en el informe se van. El archivo se compacta al instante." -storage.trades_cleanup_preview: - ru: "удалится ≈ %{prints} трейдов, освободится ≈ %{size}" - en: "≈ %{prints} prints go, ≈ %{size} freed" - es: "se borrarán ≈ %{prints} operaciones, se liberarán ≈ %{size}" -storage.trades_cleanup_empty: - ru: "нечего удалять" - en: "nothing to delete" - es: "nada que borrar" -storage.trades_cleanup_pending: - ru: "считаю…" - en: "counting…" - es: "contando…" -storage.trades_cleanup_failed: - ru: "не посчитать — %{err}" - en: "could not count — %{err}" - es: "no se pudo contar — %{err}" -storage.trades_cleanup_done: - ru: "удалено %{prints} трейдов, освобождено ≈ %{size}" - en: "%{prints} prints deleted, ≈ %{size} freed" - es: "%{prints} operaciones borradas, ≈ %{size} liberados" -storage.op_cleanup: - ru: "Чистка" - en: "Cleanup" - es: "Limpieza" + ru: "Сколько минут трейдов брать с каждого конца сделки: у короткой — до входа и после выхода, у долгой (дольше часа) — столько же вокруг входа и вокруг выхода, середина свечами. 0 — только сама сделка, не больше 120." + en: "Minutes of prints taken at each end of a trade: before the entry and after the exit of a short one; the same amount centred on the entry and on the exit of a long one (over an hour), candles between. 0 is the position alone, 120 at most." + es: "Minutos de operaciones tomados en cada extremo de una posición: antes de la entrada y después de la salida en una corta; la misma cantidad centrada en la entrada y en la salida en una larga (más de una hora), velas entre ambas. 0 es solo la posición, 120 como máximo." +storage.trades_autoload: + ru: "Подгружать недостающую ленту при старте" + en: "Fetch the missing tape at startup" + es: "Descargar la cinta que falta al arrancar" +storage.trades_autoload_hint: + ru: "Когда ядра поднялись, терминал сам запрашивает у бирж трейды недавних закрытых сделок стратегий с мс-штампами — тех, что закрылись, пока терминал не работал и захват при закрытии их не застал. Только сделки, по которым тюнер может считать: без фандинга, ликвидаций, объединённых и ручных продаж. Окно — сколько биржа ещё отдаёт (Binance фьючерсы 48 ч, Gate spot 30 дней, OKX и Bitget 90, не дальше 30 дней). Bybit и Hyperliquid публичной истории трейдов не дают. Тратит лимит публичных запросов; идёт той же очередью, что «Прогрузить трейды» в тюнере, и останавливается той же кнопкой." + en: "Once the cores are up the terminal asks the venues for the prints of recent closed strategy trades with millisecond stamps — the ones that closed while it was not running and the close-time capture missed. Only trades the tuner can be run on: no funding, liquidations, joined or manual sells. The window is what the venue still serves (Binance futures 48 h, Gate spot 30 days, OKX and Bitget 90, never past 30 days). Bybit and Hyperliquid serve no public trade history. Spends the public request budget; runs in the same queue as \"Fetch trades\" in the tuner and stops with the same button." + es: "Cuando los núcleos están arriba, el terminal pide a las bolsas las operaciones de las posiciones de estrategias cerradas recientes con marcas de milisegundos — las que cerraron mientras no estaba en marcha y la captura al cierre no vio. Solo operaciones sobre las que el ajustador puede calcular: sin funding, liquidaciones, ventas unidas ni manuales. La ventana es lo que la bolsa aún sirve (futuros Binance 48 h, Gate spot 30 días, OKX y Bitget 90, nunca más de 30 días). Bybit e Hyperliquid no dan historial público. Gasta el límite de peticiones públicas; va en la misma cola que «Cargar trades» del ajustador y se para con el mismo botón." storage.trades_hint: ru: "Файл можно удалить при закрытом терминале." en: "The file can be deleted while the terminal is closed." From 7df3dabfd55281d4241f7bbae032c2c1d8042b16 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sun, 20 Sep 2026 23:30:57 +0200 Subject: [PATCH 07/51] =?UTF-8?q?feat(storage):=20trade-replay=20margin=20?= =?UTF-8?q?in=20seconds=20on=20a=20step=20list=20(10=20s=20=E2=80=A6=20120?= =?UTF-8?q?=20min)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/config/storage.rs | 59 ++++++----------- crates/moon-core/src/config/storage/tests.rs | 65 ++----------------- .../moon-core/src/market/trade_replay/mod.rs | 7 +- .../src/market/trade_replay/settings.rs | 27 ++++---- crates/moon-ui-gpui/src/settings/storage.rs | 32 +++------ locales/storage.yml | 6 +- 6 files changed, 58 insertions(+), 138 deletions(-) diff --git a/crates/moon-core/src/config/storage.rs b/crates/moon-core/src/config/storage.rs index 598e4f5d..f1c5b054 100644 --- a/crates/moon-core/src/config/storage.rs +++ b/crates/moon-core/src/config/storage.rs @@ -61,12 +61,12 @@ pub struct TradeReplayStoreCfg { /// Ceiling on the packed prints the file may hold, in megabytes; past it the spans written /// longest ago go first. `0` keeps everything, with no age limit. pub max_mb: u32, - /// Minutes of prints kept around a trade, per end: a short position gets this many minutes - /// before its entry and after its exit; a long one (over an hour) gets this many minutes - /// centred on each end, half before and half after, with bars between. It sizes what a trade - /// window fetches, what a close copies out of the core's ring, and what the file keeps. - /// `0` is the position alone; clamped to [`MAX_TRADE_MARGIN_MIN`] on load. - pub margin_min: u32, + /// Seconds of prints kept around a trade, per end: a short position gets this much before + /// its entry and after its exit; a long one (over an hour) gets this much centred on each + /// end, half before and half after, with bars between. It sizes what a trade window fetches, + /// what a close copies out of the core's ring, and what the file keeps. One of + /// [`TRADE_MARGIN_STEPS_S`]: a hand-edited value is snapped to the nearest step on load. + pub margin_s: u32, /// Whether the terminal fetches, once the cores are up, the tape of every recent closed /// trade with millisecond stamps that the venues still serve — what the close-time capture /// missed because the terminal was not running. Off by default: it spends the venues' public @@ -88,21 +88,16 @@ pub const LONG_POSITION_MIN_RANGE: std::ops::RangeInclusive = 1..=120; pub const DEFAULT_TRADES_MAX_MB: u32 = 256; /// The values [`TradeReplayStoreCfg::margin_s`] may take, ascending: the Storage tab steps -/// through this list rather than by a fixed amount, so the short end is fine-grained and the -/// long end coarse. The floor is 30 s — the tuner's run-up and tail -/// (`trade_replay::MODEL_PAD_MS`): one setting sizes the chart's window, the close-time capture, -/// the tuner's fetch and the cleanup alike, and none of them pads it behind the tab's back (the -/// developer's call, 2026-09-23; the steps started at 5 s before that, and the tuner lifted -/// them to a minute on its own). 65 s is a step so the default survives the snap; it is not -/// the floor. The ceiling is two hours: the bar context after an exit is two hours at least, -/// and prints past the bars would have nowhere to draw. -pub const TRADE_MARGIN_STEPS_S: &[u32] = &[30, 60, 65, 180, 300, 600, 900, 1800, 3600, 7200]; +/// through this list rather than by a fixed amount, so the short end is fine-grained (10 s for a +/// scalp) and the long end coarse. The floor is 10 s — "the position alone" is gone: a window +/// with no prints outside the position has nothing to show around the entry. The ceiling is two +/// hours: the bar context after an exit is two hours at least, and prints past the bars would +/// have nowhere to draw. +pub const TRADE_MARGIN_STEPS_S: &[u32] = &[10, 30, 60, 180, 300, 600, 900, 1800, 3600, 7200]; -/// Default seconds of prints around a trade, per end. 65 s (the user's call, 2026-09-26; -/// 30 s from 2026-09-23, 5 s from 2026-09-21, 15 minutes before that). Not the floor of -/// [`TRADE_MARGIN_STEPS_S`]: 30 s stays the tuner's pad and a step, so a file that already -/// stores 30 keeps 30. A file with no margin key at all takes this default. -pub const DEFAULT_TRADE_MARGIN_S: u32 = 65; +/// Default seconds of prints around a trade, per end — 15 minutes (the developer's call, +/// 2026-09-20). +pub const DEFAULT_TRADE_MARGIN_S: u32 = 900; /// Ceiling on [`TradeReplayStoreCfg::margin_s`] — the last of [`TRADE_MARGIN_STEPS_S`]. pub const MAX_TRADE_MARGIN_S: u32 = 7200; @@ -112,7 +107,7 @@ impl Default for TradeReplayStoreCfg { Self { persist_trades: true, max_mb: DEFAULT_TRADES_MAX_MB, - margin_min: DEFAULT_TRADE_MARGIN_MIN, + margin_s: DEFAULT_TRADE_MARGIN_S, autoload_missing: false, } } @@ -128,8 +123,7 @@ struct TradeReplayStoreRaw { max_mb: u32, margin_s: Option, margin_min: Option, - long_position_min: u32, - cleanup_at_startup: bool, + autoload_missing: bool, } impl Default for TradeReplayStoreRaw { @@ -140,8 +134,7 @@ impl Default for TradeReplayStoreRaw { max_mb: d.max_mb, margin_s: None, margin_min: None, - long_position_min: d.long_position_min, - cleanup_at_startup: d.cleanup_at_startup, + autoload_missing: d.autoload_missing, } } } @@ -156,20 +149,11 @@ impl From for TradeReplayStoreCfg { persist_trades: raw.persist_trades, max_mb: raw.max_mb, margin_s, - long_position_min: raw.long_position_min, - cleanup_at_startup: raw.cleanup_at_startup, + autoload_missing: raw.autoload_missing, } } } -/// [`LONG_POSITION_MIN_RANGE`] applied to a value from the file or the tab. -pub fn clamp_long_position_min(minutes: u32) -> u32 { - minutes.clamp( - *LONG_POSITION_MIN_RANGE.start(), - *LONG_POSITION_MIN_RANGE.end(), - ) -} - /// The step of [`TRADE_MARGIN_STEPS_S`] nearest to `secs` — the lower one when `secs` sits /// exactly between two (a migrated `margin_min = 45` lands on 30 minutes, not 60). Anything /// past the last step is the last step, anything under the first is the first. @@ -248,13 +232,10 @@ pub fn load() -> StorageCfg { sanitize(toml_io::load_or_default(&path, "storage.toml", |_| {})) } -/// Bound what a hand-edited file may carry: the long-position threshold is clamped to -/// [`LONG_POSITION_MIN_RANGE`], and the margin is snapped onto [`TRADE_MARGIN_STEPS_S`], +/// Bound what a hand-edited file may carry: the margin is snapped onto [`TRADE_MARGIN_STEPS_S`], /// which also caps it at [`MAX_TRADE_MARGIN_S`]. fn sanitize(mut cfg: StorageCfg) -> StorageCfg { cfg.trade_replay.margin_s = snap_trade_margin_s(cfg.trade_replay.margin_s); - cfg.trade_replay.long_position_min = - clamp_long_position_min(cfg.trade_replay.long_position_min); cfg } diff --git a/crates/moon-core/src/config/storage/tests.rs b/crates/moon-core/src/config/storage/tests.rs index 6e1b0bd0..4bca07c6 100644 --- a/crates/moon-core/src/config/storage/tests.rs +++ b/crates/moon-core/src/config/storage/tests.rs @@ -21,36 +21,6 @@ max_mb = 512 assert!(!cfg.trade_replay.autoload_missing); } -/// The long-position threshold round-trips through the file and is bounded where `load` -/// bounds it: a hand-edited zero — every trade "long" — becomes the floor, an hour past the -/// ceiling becomes the ceiling. -#[test] -fn long_position_min_round_trips_and_is_clamped_on_load() { - let mut cfg = StorageCfg::default(); - cfg.trade_replay.long_position_min = 15; - cfg.trade_replay.cleanup_at_startup = true; - let text = toml::to_string(&cfg).expect("serialises"); - assert!(text.contains("long_position_min = 15"), "{text}"); - assert!(text.contains("cleanup_at_startup = true"), "{text}"); - let back: StorageCfg = toml::from_str(&text).expect("parses"); - assert_eq!(back.trade_replay.long_position_min, 15); - assert!(back.trade_replay.cleanup_at_startup); - let zero: StorageCfg = toml::from_str( - "[trade_replay] -long_position_min = 0 -", - ) - .expect("parses"); - assert_eq!(sanitize(zero).trade_replay.long_position_min, 1); - let huge: StorageCfg = toml::from_str( - "[trade_replay] -long_position_min = 180 -", - ) - .expect("parses"); - assert_eq!(sanitize(huge).trade_replay.long_position_min, 120); -} - /// A file written while the margin was `margin_min` (minutes) — what every terminal installed /// before 2026-09-20 has — reads as the same stretch in seconds, and the other fields survive /// the detour through the raw shape. @@ -60,11 +30,13 @@ fn old_storage_toml_with_margin_min_reads_as_seconds() { persist_trades = false max_mb = 64 margin_min = 15 +autoload_missing = true "; let cfg: StorageCfg = toml::from_str(text).expect("old file parses"); assert_eq!(cfg.trade_replay.margin_s, 900); assert!(!cfg.trade_replay.persist_trades); assert_eq!(cfg.trade_replay.max_mb, 64); + assert!(cfg.trade_replay.autoload_missing); // A migrated value off the list lands on the nearest step where `load` snaps it — the // lower one on a tie, so 45 minutes becomes 30, not 60. let odd: StorageCfg = toml::from_str("[trade_replay]\nmargin_min = 45\n").expect("parses"); @@ -102,23 +74,6 @@ margin_s = 36000 assert_eq!(sanitize(back).trade_replay.margin_s, 30); } -/// A file written while the steps started at 5 s — the default of 2026-09-21 is on disk in -/// every terminal that never touched the setting — loads at the new floor, 30 s: the tuner's -/// run-up and tail, which the setting is no longer padded to behind the tab's back. -#[test] -fn a_margin_under_the_floor_loads_at_the_floor() { - for old in [5, 10] { - let cfg: StorageCfg = toml::from_str(&format!("[trade_replay]\nmargin_s = {old}\n")) - .expect("old file parses"); - assert_eq!(sanitize(cfg).trade_replay.margin_s, 30, "margin_s = {old}"); - } - assert_eq!( - i64::from(TRADE_MARGIN_STEPS_S[0]) * 1_000, - crate::market::trade_replay::MODEL_PAD_MS, - "the floor is the tuner's pad" - ); -} - /// The step list is what the snap and the stepper agree on: every step snaps to itself, the /// ends absorb what lies beyond them, and the default and the ceiling are both members. #[test] @@ -131,13 +86,10 @@ fn snap_and_step_walk_the_step_list() { TRADE_MARGIN_STEPS_S.last().copied(), Some(MAX_TRADE_MARGIN_S) ); - assert_eq!(snap_trade_margin_s(0), 30); - assert_eq!(snap_trade_margin_s(44), 30); - assert_eq!(snap_trade_margin_s(45), 30, "tie goes to the lower step"); - assert_eq!(snap_trade_margin_s(46), 60); - assert_eq!(snap_trade_margin_s(65), 65); - assert_eq!(snap_trade_margin_s(62), 60); - assert_eq!(snap_trade_margin_s(63), 65); + assert_eq!(snap_trade_margin_s(0), 10); + assert_eq!(snap_trade_margin_s(19), 10); + assert_eq!(snap_trade_margin_s(20), 10, "tie goes to the lower step"); + assert_eq!(snap_trade_margin_s(21), 30); assert_eq!(snap_trade_margin_s(u32::MAX), MAX_TRADE_MARGIN_S); assert_eq!(step_trade_margin_s(900, 1), 1800); @@ -148,10 +100,7 @@ fn snap_and_step_walk_the_step_list() { 7200, "the top absorbs the rest" ); - assert_eq!(step_trade_margin_s(30, -1), 30, "so does the bottom"); - assert_eq!(step_trade_margin_s(60, 1), 65); - assert_eq!(step_trade_margin_s(65, 1), 180); - assert_eq!(step_trade_margin_s(65, -1), 60); + assert_eq!(step_trade_margin_s(10, -1), 10, "so does the bottom"); assert_eq!( step_trade_margin_s(2700, 1), 3600, diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index 124ea780..e0f967a0 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -42,7 +42,7 @@ use crate::market::candles::ChartCandle; use crate::market::{CandleReadParams, ChartHistoryBuffers, ChartHistoryRead}; use crate::venue::{Brand, Venue}; pub use coverage::Coverage; -pub use settings::{margin_ms, model_margin_ms, set_margin_min, set_tape_autoload, tape_autoload}; +pub use settings::{margin_ms, model_margin_ms, set_margin_s, set_tape_autoload, tape_autoload}; pub use worker::{TickAnswer, TickQuery, query_held}; /// Milliseconds in one minute, the only timeframe a replay is fetched at. @@ -307,10 +307,11 @@ pub struct ReplayWindow { /// Millisecond-exact when the core supplied a millisecond column, whole seconds otherwise. pub close_ms: i64, /// How many milliseconds of prints are asked for around the position, per end — the - /// `[trade_replay] margin_min` setting at the moment the window was built (floored for a + /// `[trade_replay] margin_s` setting at the moment the window was built (floored for a /// model's window, see `model_margin_ms`). A short position /// gets this much before the entry and after the exit ([`Self::focus`]); a long one gets it - /// centred on each end ([`Self::focus_spans`]). Zero is the position alone. + /// centred on each end ([`Self::focus_spans`]). Zero is the position alone — a value the + /// setting no longer offers, but one a hand-built window may still carry. pub margin_ms: i64, /// How long a position must be held to be walked as its two ends — the `[trade_replay] /// long_position_min` setting at the moment the window was built ([`long_position_ms`]), diff --git a/crates/moon-core/src/market/trade_replay/settings.rs b/crates/moon-core/src/market/trade_replay/settings.rs index 31e67cb9..4676efd9 100644 --- a/crates/moon-core/src/market/trade_replay/settings.rs +++ b/crates/moon-core/src/market/trade_replay/settings.rs @@ -8,11 +8,9 @@ use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use super::MINUTE_MS; - -/// Live value of `[trade_replay] margin_min` — how many minutes of prints a window asks for +/// Live value of `[trade_replay] margin_s` — how many seconds of prints a window asks for /// around a trade, per end ([`super::ReplayWindow::margin_ms`]). -static MARGIN_MIN: AtomicU32 = AtomicU32::new(crate::config::storage::DEFAULT_TRADE_MARGIN_MIN); +static MARGIN_S: AtomicU32 = AtomicU32::new(crate::config::storage::DEFAULT_TRADE_MARGIN_S); /// Live value of `[trade_replay] autoload_missing`. static TAPE_AUTOLOAD: AtomicBool = AtomicBool::new(false); static INIT: OnceLock<()> = OnceLock::new(); @@ -22,8 +20,14 @@ static INIT: OnceLock<()> = OnceLock::new(); fn init() { INIT.get_or_init(|| { let cfg = crate::config::storage::load(); - MARGIN_MIN.store(cfg.trade_replay.margin_min, Ordering::Relaxed); + MARGIN_S.store(cfg.trade_replay.margin_s, Ordering::Relaxed); TAPE_AUTOLOAD.store(cfg.trade_replay.autoload_missing, Ordering::Relaxed); + // Once per launch, so a file migrated from `margin_min` shows what it was read as. + log::info!( + "[x] trade-replay settings: margin {} s, tape autoload {}", + cfg.trade_replay.margin_s, + cfg.trade_replay.autoload_missing + ); }); } @@ -31,13 +35,13 @@ fn init() { /// close-time capture is built with. pub fn margin_ms() -> i64 { init(); - i64::from(MARGIN_MIN.load(Ordering::Relaxed)) * MINUTE_MS + i64::from(MARGIN_S.load(Ordering::Relaxed)) * 1_000 } /// The margin a MODEL's window is built with: the chart's margin, but never less than TWICE /// the model's own pad ([`super::MODEL_PAD_MS`]). The plan's trade tiles and the model's /// required span are both clipped to the window's focus, so a chart margin under the pad — -/// "the position alone" is a valid setting — would otherwise leave the model without its +/// 10 s is a valid setting — would otherwise leave the model without its /// run-up and its tail and never say so. Twice, because a long position's focus centres the /// margin on each end ([`super::ReplayWindow::focus_spans`]): half of it lies outside the /// position, and that half must still be a whole pad. @@ -47,11 +51,12 @@ pub fn model_margin_ms() -> i64 { /// Move the live margin; the Storage tab writes `storage.toml` beside this. Windows already open /// keep the margin they were built with; the next one asks for the new stretch, and the tile -/// store hands back what earlier windows already fetched of it. -pub fn set_margin_min(minutes: u32) { +/// store hands back what earlier windows already fetched of it. Snapped onto the step list like +/// the file is on load, so the cell never holds a value the tab cannot show. +pub fn set_margin_s(secs: u32) { init(); - MARGIN_MIN.store( - minutes.min(crate::config::storage::MAX_TRADE_MARGIN_MIN), + MARGIN_S.store( + crate::config::storage::snap_trade_margin_s(secs), Ordering::Relaxed, ); } diff --git a/crates/moon-ui-gpui/src/settings/storage.rs b/crates/moon-ui-gpui/src/settings/storage.rs index cec25cb4..e6e18ddf 100644 --- a/crates/moon-ui-gpui/src/settings/storage.rs +++ b/crates/moon-ui-gpui/src/settings/storage.rs @@ -210,8 +210,8 @@ impl SettingsView { } /// Moves the prints kept around a trade, per end, `delta` steps along - /// `TRADE_MARGIN_STEPS_S` (30 s … 120 min, including 65 s, not a fixed amount), and updates - /// live state and storage.toml. + /// `TRADE_MARGIN_STEPS_S` (10 s … 120 min, not a fixed amount), and updates live state and + /// storage.toml. fn adjust_trades_margin_step(&mut self, delta: i32, cx: &mut Context) { let v = storage_cfg::step_trade_margin_s(self.storage.cfg.trade_replay.margin_s, delta); if self.storage.cfg.trade_replay.margin_s != v { @@ -225,29 +225,13 @@ impl SettingsView { } } - /// Moves the minutes a position must be held to count as long, clamped to - /// `LONG_POSITION_MIN_RANGE`, and updates live state and storage.toml. The cleanup's count - /// moves with it: a long position claims its two ends, a short one its whole length. - fn adjust_long_position_min(&mut self, delta: i32, cx: &mut Context) { - let current = self.storage.cfg.trade_replay.long_position_min as i32; - let v = storage_cfg::clamp_long_position_min((current + delta).max(0) as u32); - if self.storage.cfg.trade_replay.long_position_min != v { - self.storage.cfg.trade_replay.long_position_min = v; - moon_core::market::trade_replay::set_long_position_min(v); - storage_cfg::save(&self.storage.cfg); - self.storage_cleanup_refresh(cx); - cx.notify(); - } - } - - /// The stepper's label for a margin: a whole number of minutes when the step divides by - /// 60, seconds otherwise. 65 s is a step and must not read as "1 min", which is what 60 s - /// already says. + /// The stepper's label for a margin: whole seconds under a minute, whole minutes from + /// there — every step of `TRADE_MARGIN_STEPS_S` is one or the other. fn trades_margin_label(secs: u32) -> String { - if secs % 60 == 0 { - t!("storage.trades_min", min = secs / 60).to_string() - } else { + if secs < 60 { t!("storage.trades_sec", s = secs).to_string() + } else { + t!("storage.trades_min", min = secs / 60).to_string() } } @@ -266,7 +250,7 @@ impl SettingsView { let limit = self.storage.cfg.strategies.version_limit; let persist_trades = self.storage.cfg.trade_replay.persist_trades; let trades_max_mb = self.storage.cfg.trade_replay.max_mb; - let trades_margin_min = self.storage.cfg.trade_replay.margin_min; + let trades_margin_s = self.storage.cfg.trade_replay.margin_s; let autoload_missing = self.storage.cfg.trade_replay.autoload_missing; let size_line = |sz: Option<(u64, u64)>| -> String { diff --git a/locales/storage.yml b/locales/storage.yml index 0146e975..7091969b 100644 --- a/locales/storage.yml +++ b/locales/storage.yml @@ -129,9 +129,9 @@ storage.trades_min: en: "%{min} min" es: "%{min} min" storage.trades_margin_hint: - ru: "Сколько минут трейдов брать с каждого конца сделки: у короткой — до входа и после выхода, у долгой (дольше часа) — столько же вокруг входа и вокруг выхода, середина свечами. 0 — только сама сделка, не больше 120." - en: "Minutes of prints taken at each end of a trade: before the entry and after the exit of a short one; the same amount centred on the entry and on the exit of a long one (over an hour), candles between. 0 is the position alone, 120 at most." - es: "Minutos de operaciones tomados en cada extremo de una posición: antes de la entrada y después de la salida en una corta; la misma cantidad centrada en la entrada y en la salida en una larga (más de una hora), velas entre ambas. 0 es solo la posición, 120 como máximo." + ru: "Сколько трейдов брать с каждого конца сделки: у короткой — до входа и после выхода, у долгой (дольше часа) — столько же вокруг входа и вокруг выхода, середина свечами. Ступени от 10 с до 120 мин." + en: "Prints taken at each end of a trade: before the entry and after the exit of a short one; the same stretch centred on the entry and on the exit of a long one (over an hour), candles between. Steps from 10 s to 120 min." + es: "Operaciones tomadas en cada extremo de una posición: antes de la entrada y después de la salida en una corta; el mismo tramo centrado en la entrada y en la salida en una larga (más de una hora), velas entre ambas. Pasos de 10 s a 120 min." storage.trades_autoload: ru: "Подгружать недостающую ленту при старте" en: "Fetch the missing tape at startup" From 23f113f22fa60f08f05f04b6f311e6fa52a87524 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sun, 20 Sep 2026 23:30:57 +0200 Subject: [PATCH 08/51] =?UTF-8?q?feat(tuner):=20deal=20table=20of=20the=20?= =?UTF-8?q?Entry/Exit=20axis=20=E2=80=94=20USDT=20profit,=20held=20tape,?= =?UTF-8?q?=20one=20exit=20horizon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/db/analytics/mod.rs | 34 +++ crates/moon-core/src/db/tuner/mod.rs | 31 +++ crates/moon-core/src/db/tuner/ticks/deals.rs | 71 +++++- .../src/db/tuner/ticks/deals/tests.rs | 27 +++ .../src/db/tuner/ticks/line/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/mod.rs | 10 +- crates/moon-core/src/db/tuner/ticks/search.rs | 39 ++++ .../src/db/tuner/ticks/search/tests.rs | 69 ++++++ .../src/db/tuner/ticks/stats/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/tests.rs | 1 + .../src/analytics/tuner/ticks/columns.rs | 49 ++-- .../src/analytics/tuner/ticks/fetch.rs | 18 +- .../src/analytics/tuner/ticks/fetch/job.rs | 7 +- .../src/analytics/tuner/ticks/load.rs | 51 ++++- .../src/analytics/tuner/ticks/mod.rs | 211 +++++++++++++----- .../src/analytics/tuner/ticks/rows.rs | 14 +- .../src/analytics/tuner/ticks/rows/tests.rs | 37 ++- .../src/analytics/tuner/ticks/state.rs | 30 ++- .../src/analytics/tuner/ticks/variants.rs | 18 +- .../src/trade_window/open_record.rs | 56 +---- locales/analytics.yml | 72 +++--- 21 files changed, 634 insertions(+), 213 deletions(-) diff --git a/crates/moon-core/src/db/analytics/mod.rs b/crates/moon-core/src/db/analytics/mod.rs index 7461f1c5..c218c461 100644 --- a/crates/moon-core/src/db/analytics/mod.rs +++ b/crates/moon-core/src/db/analytics/mod.rs @@ -440,6 +440,40 @@ fn scope_decision_on(conn: &Connection, q: &Query) -> ReadResult }) } +/// The row-level projection under which the scope's money reads in USDT, or `None` when it +/// cannot: the money lens's own decision with USDT asked for (`prefer_usdt`), accepted only +/// when the unit it publishes IS USDT — a pure USDT scope (native, already USDT), or a scope +/// the valuation covers (converted). A single non-USDT quote without coverage decides +/// `Native` in its own quote, a mixed or unknown scope splits: neither is USDT, and a caller +/// that labels a column "USDT" must show nothing there rather than that money. +/// +/// Args: +/// conn: Open report reader or pinned snapshot. +/// q: Concrete Analytics query; its metric is ignored. +/// +/// Returns: +/// The USDT projection, or `None` when the scope's money cannot be valued in USDT. +/// +/// Errors: +/// Returns a classified report read failure when quote or valuation preflight cannot complete. +pub(in crate::db) fn usdt_projection_on( + conn: &Connection, + q: &Query, +) -> ReadResult> { + let mut money = q.clone(); + money.metric = ProfitMetric::Quote; + money.prefer_usdt = true; + Ok(match scope_decision_on(conn, &money)? { + ScopeDecision::Comparable { unit, projection } + if unit == ProfitUnit::Quote(crate::db::QuoteCurrency::usdt()) => + { + Some(projection) + } + ScopeDecision::Empty { projection } => Some(projection), + ScopeDecision::Comparable { .. } | ScopeDecision::Split(_) => None, + }) +} + /// Resolve one query to a safe row-level projection for non-`ProfitScope` consumers. /// /// Args: diff --git a/crates/moon-core/src/db/tuner/mod.rs b/crates/moon-core/src/db/tuner/mod.rs index aa1b6524..3b24e6f1 100644 --- a/crates/moon-core/src/db/tuner/mod.rs +++ b/crates/moon-core/src/db/tuner/mod.rs @@ -402,6 +402,37 @@ fn tuner_read_on( }) } +/// A SECOND unified source beside the tuner's own, with the money columns VALUED IN USDT and +/// nothing else changed: the Entry/Exit axis shows a deal's profit as money beside its per +/// cent, and that money must read the same for a BTC-quoted core and a USDT one. It is not +/// the scan's source — the projection it carries is a money projection, and `pnl` under it +/// would be money whatever the metric (`unified_from_mode` derives per cent from the +/// projection, not from the query) — so the scan keeps [`tuner_source_on`] and reads only the +/// USDT `profitbtc` off this one, in the same snapshot. `None` when the scope's money cannot +/// be valued in USDT at all (`usdt_projection_on`: a non-USDT quote without valuation +/// coverage, a mixed scope that splits) — the scan still serves the axis then, and the money +/// column stays empty rather than showing another currency under a USDT heading. +/// +/// Args: +/// conn: The snapshot the scan runs in. +/// q: Report scope and period, floored or not — floored here like the scan's. +/// +/// Returns: +/// The `FROM` source, `None` when the money cannot be USDT, or `NotReady` when no source +/// can answer. +pub(super) fn tuner_source_usdt_on(conn: &Connection, q: &Query) -> ReadResult> { + let mut money = q.clone(); + money.floor_all_history(); + money.metric = crate::db::ProfitMetric::Quote; + money.prefer_usdt = true; + let Some(projection) = crate::db::analytics::usdt_projection_on(conn, &money)? else { + return Ok(None); + }; + crate::db::analytics::unified_from_mode(conn, &money, projection)? + .map(Some) + .ok_or(ReadFail::NotReady) +} + /// Open a reader and materialize tuner rows in the same snapshot as quote preflight. /// /// The snapshot ends when `read` returns, before the caller performs CPU-heavy optimization. diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index c92405fb..cec76768 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -10,6 +10,8 @@ //! "Fact" column keeps them all: it is the scope's money, and this file decides only what the //! model reads. +use std::collections::HashMap; + use rusqlite::Connection; use super::scope::{is_service_row, is_tunable}; @@ -62,7 +64,20 @@ const DELTA_COLS: [&str; 12] = [ /// The replayable deals with their kinds resolved, and the counts left out; `NotReady` /// when no report source has the schema yet. pub fn read_deals(q: &Query) -> ReadResult { - let mut read = crate::db::tuner::read_tuner_rows(q, read_on)?; + // The scan on the tuner's own source (the metric decides `pnl`), then the USDT money of + // every row off the USDT source in the same snapshot — the table's profit column must not + // change unit with the scope's quote, and the scan's `profitbtc` would. + let mut read = crate::db::tuner::read_tuner_rows(q, |conn, q, src| { + let mut read = read_on(conn, q, src)?; + match crate::db::tuner::tuner_source_usdt_on(conn, q)? { + Some(usdt_src) => overlay_usdt_profit(conn, q, &usdt_src, &mut read.deals)?, + None => log::info!( + target: crate::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks deals: the scope's money cannot be valued in USDT, the profit column stays empty" + ), + } + Ok(read) + })?; // The kind selects the entry model; resolved once per distinct strategy, off the replica's // snapshot, because it lives in strategies.sqlite. let mut pairs: Vec<(i64, u64)> = read @@ -171,6 +186,8 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { is_short: int(9)? != 0, sell_reason, fact_pnl: num(11)?, + // Filled by `overlay_usdt_profit` off the USDT source, when there is one. + profit: None, deltas, tick: None, }); @@ -184,5 +201,57 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { Ok(out) } +/// Fill [`Deal::profit`] of every deal with the row's `profitbtc` off the USDT-valued source +/// (`tuner_source_usdt_on`), keyed by `reportuid`. A deal the USDT source does not carry — +/// a row that joined the replica between the two scans of one snapshot cannot exist, so this +/// is a source that projects the row differently — stays unpriced and is counted in the log. +/// +/// Args: +/// conn: The snapshot the scan ran in. +/// q: The floored query the scan ran with (its period bounds are the parameters). +/// usdt_src: The USDT `FROM` source. +/// deals: The scanned deals, filled in place. +fn overlay_usdt_profit( + conn: &Connection, + q: &Query, + usdt_src: &str, + deals: &mut [Deal], +) -> ReadResult<()> { + const CTX: &str = "tuner: ticks deals (USDT money)"; + let sql = format!("SELECT o.\"reportuid\", COALESCE(o.\"profitbtc\", 0) FROM {usdt_src}"); + let mut stmt = conn.prepare(&sql).map_err(|e| read_fail_on(conn, CTX, e))?; + let mut rows = stmt + .query(rusqlite::params![q.from, q.to]) + .map_err(|e| read_fail_on(conn, CTX, e))?; + let mut money: HashMap = HashMap::new(); + while let Some(r) = rows.next().map_err(|e| read_fail_on(conn, CTX, e))? { + let uid = r + .get::<_, Option>(0) + .map_err(|e| read_fail_on(conn, CTX, e))? + .unwrap_or(0); + let profit = r + .get::<_, Option>(1) + .map_err(|e| read_fail_on(conn, CTX, e))? + .filter(|v| v.is_finite()) + .unwrap_or(0.0); + money.insert(uid, profit); + } + let mut unpriced = 0usize; + for deal in deals.iter_mut() { + deal.profit = money.get(&deal.report_uid).copied(); + if deal.profit.is_none() { + unpriced += 1; + } + } + if unpriced > 0 { + log::warn!( + target: crate::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks deals: {unpriced} of {} deal(s) missing from the USDT source, profit left empty", + deals.len() + ); + } + Ok(()) +} + #[cfg(test)] mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/deals/tests.rs b/crates/moon-core/src/db/tuner/ticks/deals/tests.rs index d4b98e36..731bcaac 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals/tests.rs @@ -65,6 +65,33 @@ fn rows_with_stamps_become_deals_and_the_rest_are_counted() { assert!((ace.deltas.pricebug - 0.5).abs() < 1e-9); // Percent metric: 10 / 1000 · 100. assert!((ace.fact_pnl - 1.0).abs() < 1e-9, "{}", ace.fact_pnl); + // The scan leaves the USDT money to the overlay. + assert_eq!(ace.profit, None); +} + +/// The USDT money of every deal comes off the USDT source in the same snapshot, keyed by the +/// row's `reportuid`: on this pure-USDT replica the source resolves native (already USDT) and +/// the overlay hands each deal its `profitbtc`, sign and all, while `fact_pnl` stays what the +/// metric made it. +#[test] +fn the_usdt_overlay_fills_profit_by_report_uid() { + let conn = replica(); + let (q, src) = tuner_source_on(&conn, &scope()).expect("source"); + let mut read = read_on(&conn, &q, &src).expect("read"); + let usdt_src = crate::db::tuner::tuner_source_usdt_on(&conn, &q) + .expect("usdt source") + .expect("a pure-USDT replica is USDT as it is"); + overlay_usdt_profit(&conn, &q, &usdt_src, &mut read.deals).expect("overlay"); + let ben = &read.deals[0]; + let ace = &read.deals[1]; + assert_eq!((ben.report_uid, ace.report_uid), (12, 11)); + assert_eq!(ben.profit, Some(-10.0)); + assert_eq!(ace.profit, Some(10.0)); + assert!((ace.fact_pnl - 1.0).abs() < 1e-9, "per cent, not money"); + assert!( + (ace.spent - 1000.0).abs() < 1e-9, + "the spend stays the scan's" + ); // A NULL delta reads as zero, never as a missing row. assert_eq!(read.deals[0].deltas.d1h, 0.0); assert!( diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index 96d6c145..55caf4e4 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -33,6 +33,7 @@ fn deal(short: bool) -> Deal { is_short: short, sell_reason: "Auto Price Down".into(), fact_pnl: 5.0, + profit: None, deltas: Deltas::default(), tick: None, } diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index ee6ced4e..9582f36e 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -121,7 +121,8 @@ pub struct Deal { pub close_ms: i64, pub buy_price: f64, pub sell_price: f64, - /// `spentbtc` — what the entry cost, in the row's quote currency; the money KPI of a variant + /// `spentbtc` — what the entry cost, in the scan's own money unit (the row's quote, or + /// USDT where the scan's projection converts a valued scope); the money KPI of a variant /// is `profit_pct * spent` so the columns stay in the units of the "Fact" column. pub spent: f64, pub is_short: bool, @@ -130,6 +131,13 @@ pub struct Deal { /// The row's result in the scope's active metric (`pnl` of the unified source) — what the /// "Fact" column over the same subset sums. pub fact_pnl: f64, + /// The row's result as MONEY IN USDT whatever the metric — `profitbtc` of the USDT-valued + /// source (`read_deals`), fees as the core wrote them, converted like the Report's USDT + /// column; `None` when the scope's money cannot be valued in USDT (a non-USDT quote + /// without valuation coverage, a mixed scope), and the table shows a dash. The table's + /// profit column alone reads it — the KPI stays in the scope's own unit through + /// `fact_pnl` and `spent`. + pub profit: Option, pub deltas: Deltas, /// Price step of the market, when the caller could resolve it (the live catalog, or /// [`infer_tick`] over the window). `None` disables the step-bound rules and rounds nothing. diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index 03bb1131..f34a574a 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -42,6 +42,45 @@ pub struct PreparedDeal { pub ticks: Arc<[Tick]>, /// The archived first point of the entry line, when the archive holds it. pub entry_start: Option<(i64, f64)>, + /// How far past the close the HELD COVERAGE of this deal's window reaches, in + /// milliseconds — the caller's word from the tile store, not the last print's stamp: a + /// quiet market prints nothing for seconds, and a tail measured by its last print would + /// read as shorter than what is actually held. What [`common_horizon_ms`] takes the + /// sample's horizon from; a covered row holds at least the model's tail + /// (`required_spans`), so it is never under `TAIL_MS` there. + pub trail_ms: i64, +} + +/// Cut every deal's tape at the same distance past its close — the exit horizon the whole +/// sample is judged on. +/// +/// The tapes of a sample were captured under different margins (the setting moves; a close +/// filed under 5 min sits beside one filed under 30 s), and a variant judged on each deal's OWN +/// tape end is judged unevenly: on the long tape it gets minutes to reach its take, on the +/// short one seconds, and a variant that outlives the tape drops out of the tally +/// (`OpenAtWindowEnd`) — the long tapes then flatter every slow exit. One horizon for all, +/// the shortest trail among them, is the only fair comparison the sample allows; a deal a +/// variant has not closed by then still drops out, but now every deal drops out at the same +/// distance. The decision of 2026-09-20. +/// +/// Args: +/// deals: The prepared sample; a deal whose tape already ends at or before the horizon is +/// left untouched. +/// horizon_ms: The horizon past each close, from [`common_horizon_ms`]. +pub fn clip_to_horizon(deals: &mut [PreparedDeal], horizon_ms: i64) { + for deal in deals.iter_mut() { + let end_ms = deal.deal.close_ms.saturating_add(horizon_ms.max(0)); + let keep = deal.ticks.partition_point(|t| (t.time_ms as i64) <= end_ms); + if keep < deal.ticks.len() { + deal.ticks = Arc::from(&deal.ticks[..keep]); + } + } +} + +/// The exit horizon a sample allows: the shortest held trail past the close among its deals +/// ([`PreparedDeal::trail_ms`]), or `None` for an empty sample. See [`clip_to_horizon`]. +pub fn common_horizon_ms(deals: &[PreparedDeal]) -> Option { + deals.iter().map(|d| d.trail_ms.max(0)).min() } /// What one search varies and how. diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 6da2dc8c..06ee766e 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -33,6 +33,7 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { is_short: false, sell_reason: "Sell Price".into(), fact_pnl: 2.0, + profit: None, deltas: Deltas::default(), tick: None, }; @@ -48,9 +49,18 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { deal, ticks: Arc::from(ticks), entry_start: None, + trail_ms: 0, } } +/// The stamp of a tape's last print past the deal's close. +fn tape_end_ms(d: &PreparedDeal) -> i64 { + d.ticks + .last() + .map(|t| (t.time_ms as i64) - d.deal.close_ms) + .unwrap_or(0) +} + fn base() -> HashMap { [("SellPrice", "0.2"), ("StopLoss", "0")] .into_iter() @@ -171,3 +181,62 @@ fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { assert!(suggest(&deals, ¶ms, &handle).is_none()); assert!(handle.abandoned()); } + +/// The sample is judged on ONE exit horizon — the shortest HELD trail among its deals, the +/// coverage's word rather than the last print's: a tape that prints past the horizon is cut +/// there, a tape that prints less is left alone, a print exactly on the horizon stays, and a +/// quiet tail (held 8 s, last print at the close) does not shorten the horizon below what +/// is held. +#[test] +fn the_common_horizon_is_the_shortest_held_trail_and_clips_only_the_longer_tapes() { + let mut long = prepared(1, 101.0); + let close = long.deal.close_ms; + // Prints 5 s and 10 s past the close, on top of the fixture's last print AT the close. + let mut ticks: Vec = long.ticks.to_vec(); + ticks.push(tick(close + 5_000, 100.1)); + ticks.push(tick(close + 10_000, 100.0)); + long.ticks = Arc::from(ticks); + long.trail_ms = 10_000; + let mut short = prepared(2, 101.0); + let close2 = short.deal.close_ms; + let mut ticks: Vec = short.ticks.to_vec(); + ticks.push(tick(close2 + 5_000, 100.3)); + short.ticks = Arc::from(ticks); + short.trail_ms = 5_000; + // Held 8 s past the close, but the market printed nothing there. + let mut quiet = prepared(3, 101.0); + quiet.trail_ms = 8_000; + assert_eq!( + tape_end_ms(&quiet), + 0, + "the fixture's tape ends at the close" + ); + + let mut deals = vec![long.clone(), short.clone(), quiet.clone()]; + assert_eq!(common_horizon_ms(&deals), Some(5_000)); + assert_eq!(common_horizon_ms(&[]), None); + clip_to_horizon(&mut deals, 5_000); + assert_eq!( + tape_end_ms(&deals[0]), + 5_000, + "the long tape is cut at the horizon, the print on it stays" + ); + assert_eq!(deals[0].ticks.len(), long.ticks.len() - 1); + assert_eq!( + deals[1].ticks.len(), + short.ticks.len(), + "the short one is untouched" + ); + assert_eq!(deals[2].ticks.len(), quiet.ticks.len()); + // A quiet deal alone with the long one: the horizon is what it HOLDS, 8 s, not the + // zero its last print would say — the long tape keeps its 5-s print and loses the 10-s one. + let mut with_quiet = vec![long.clone(), quiet]; + let horizon = common_horizon_ms(&with_quiet).expect("two deals"); + assert_eq!(horizon, 8_000); + clip_to_horizon(&mut with_quiet, horizon); + assert_eq!(tape_end_ms(&with_quiet[0]), 5_000); + // A negative trail (a hand-built deal) reads as zero, never as a horizon before the close. + let mut odd = long; + odd.trail_ms = -1; + assert_eq!(common_horizon_ms(&[odd]), Some(0)); +} diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index 106546a8..64a42c97 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -16,6 +16,7 @@ fn deal(pnl: f64, spent: f64) -> Deal { is_short: false, sell_reason: String::new(), fact_pnl: pnl, + profit: None, deltas: Deltas::default(), tick: None, } diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index da23f59d..7ee3a235 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -42,6 +42,7 @@ fn deal() -> Deal { is_short: false, sell_reason: "Sell Price".into(), fact_pnl: 10.0, + profit: None, deltas: Deltas::default(), tick: None, } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs index a0399879..58a2b72c 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs @@ -25,15 +25,10 @@ pub(in crate::analytics::tuner) enum Align { pub(in crate::analytics::tuner) const COL_COIN: &str = "coin"; pub(in crate::analytics::tuner) const COL_TIME: &str = "time"; -pub(in crate::analytics::tuner) const COL_BUY: &str = "buy"; -pub(in crate::analytics::tuner) const COL_SELL: &str = "sell"; pub(in crate::analytics::tuner) const COL_RESULT: &str = "result"; +pub(in crate::analytics::tuner) const COL_PROFIT: &str = "profit"; pub(in crate::analytics::tuner) const COL_DURATION: &str = "duration"; -pub(in crate::analytics::tuner) const COL_D5S: &str = "d5s"; -pub(in crate::analytics::tuner) const COL_D1M: &str = "d1m"; -pub(in crate::analytics::tuner) const COL_D1H: &str = "d1h"; -pub(in crate::analytics::tuner) const COL_DMARK: &str = "dmark"; -pub(in crate::analytics::tuner) const COL_PRICEBUG: &str = "pricebug"; +pub(in crate::analytics::tuner) const COL_HELD: &str = "held"; pub(in crate::analytics::tuner) const COL_REASON: &str = "reason"; pub(in crate::analytics::tuner) const COL_TAPE: &str = "tape"; pub(in crate::analytics::tuner) const COL_MODEL: &str = "model"; @@ -48,8 +43,11 @@ const fn col(key: &'static str, label: &'static str, w: f32, min_w: f32, align: } } -/// The columns after the coin, in reading order: when, the two prices and what came of them, -/// the market at the buy, why it closed, and the two marks of this axis. +/// The columns after the coin, in reading order: when, what came of it (per cent and money), +/// how long it was held, how much tape the terminal holds around it, why it closed, and the +/// two marks of this axis. The prices and the market deltas were dropped on 2026-09-20: this +/// table is the axis's SAMPLE — which trades have their tape and how the model does on them — +/// and every other figure is one double-click away in the trade window. pub(in crate::analytics::tuner) const DEAL_COLS: &[DealCol] = &[ col( COL_TIME, @@ -58,14 +56,6 @@ pub(in crate::analytics::tuner) const DEAL_COLS: &[DealCol] = &[ 56.0, Align::Right, ), - col(COL_BUY, "analytics.ticks.col.buy", 72.0, 56.0, Align::Right), - col( - COL_SELL, - "analytics.ticks.col.sell", - 72.0, - 56.0, - Align::Right, - ), col( COL_RESULT, "analytics.ticks.col.result", @@ -73,6 +63,13 @@ pub(in crate::analytics::tuner) const DEAL_COLS: &[DealCol] = &[ 48.0, Align::Right, ), + col( + COL_PROFIT, + "analytics.ticks.col.profit", + 72.0, + 56.0, + Align::Right, + ), col( COL_DURATION, "analytics.ticks.col.duration", @@ -80,21 +77,11 @@ pub(in crate::analytics::tuner) const DEAL_COLS: &[DealCol] = &[ 44.0, Align::Right, ), - col(COL_D5S, "analytics.ticks.col.d5s", 46.0, 40.0, Align::Right), - col(COL_D1M, "analytics.ticks.col.d1m", 46.0, 40.0, Align::Right), - col(COL_D1H, "analytics.ticks.col.d1h", 46.0, 40.0, Align::Right), - col( - COL_DMARK, - "analytics.ticks.col.dmark", - 46.0, - 40.0, - Align::Right, - ), col( - COL_PRICEBUG, - "analytics.ticks.col.pricebug", - 46.0, - 40.0, + COL_HELD, + "analytics.ticks.col.held", + 76.0, + 60.0, Align::Right, ), col( diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index 86803c24..a3130a6f 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -79,6 +79,7 @@ impl FetchResolver { let tick = self.source.price_step(deal.core_uid, &market); Some(Arc::new(RowAddress { core_uid: deal.core_uid, + venue: address.venue, exchange_key: address.exchange_key, market, tick, @@ -208,23 +209,6 @@ impl AnalyticsView { } } - /// How many fetchable rows of the table a running batch does not know yet — what a press - /// would add. Zero while no batch runs (then every fetchable row is what a press starts). - pub(in crate::analytics::tuner) fn ticks_fetch_addable(&self) -> usize { - let Some(data) = self.ticks.data.data() else { - return 0; - }; - // While the tape stage reads, every row is "missing" without meaning it, and a press - // would add nothing (`ticks_fetch_missing` waits for the fold): no button then. - if self.ticks.tape_reading || !job::progress().active { - return 0; - } - let known = job::known_uids(); - data.fetchable() - .filter(|row| !known.contains(&row.deal.report_uid)) - .count() - } - /// Abandon the batch; every request in flight is cancelled by the job, and the startup /// autoload, if it still has rows to add, adds none. pub(in crate::analytics::tuner) fn ticks_fetch_stop(&mut self, cx: &mut Context) { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs index dd0a2cbc..5a8db67a 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -365,12 +365,6 @@ pub(in crate::analytics::tuner) fn attach() -> mpsc::Receiver { rx } -/// Every id the batch knows — queued, waiting, out, or answered since it started — so a view -/// can count what it could still add to a running batch. -pub(in crate::analytics::tuner) fn known_uids() -> HashSet { - lock(job()).known() -} - /// The job as the caption reads it. pub(in crate::analytics::tuner) fn progress() -> Progress { let st = lock(job()); @@ -717,6 +711,7 @@ fn serve_cluster( address: Some(row.address.clone()), ticks: None, entry_start: None, + held: None, }; replay_row(&mut answer, defaults, lines); let mut wait = retry_wait(status, answer.tape).map(|s| Duration::from_secs(u64::from(s))); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 3741aa94..04e7fb53 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -32,8 +32,10 @@ use moon_core::db::tuner::ticks::{ use moon_core::db::tuner::{VarStats, Variant, strategy_current_values, strategy_values_at}; use moon_core::feed::report_traces::ArchivedLineKind; use moon_core::feed::types::Tick; +use moon_core::market::trade_replay::venue_caps::trade_route; +use moon_core::market::trade_replay::worker::inside_retention; use moon_core::market::trade_replay::{ - Coverage, TickQuery, model_margin_ms, query_held, replay_window_ms, + Coverage, TickQuery, TickStatus, model_margin_ms, query_held, replay_window_ms, }; /// How long a held query waits for the worker's answer. The coordinator answers held queries @@ -211,6 +213,7 @@ impl AnalyticsView { address, ticks: None, entry_start: None, + held: None, } }) .collect(); @@ -289,13 +292,28 @@ impl AnalyticsView { address: Some(address), ticks: None, entry_start: None, + held: None, }) .collect(); let mut traces = archived_lines(&rows); + let now_ms = moon_core::util::now_unix_ms_i64(); for row in &mut rows { let lines = traces.remove(&row.deal.report_uid).unwrap_or_default(); let tape = tapes.remove(&row.deal.report_uid); + let answered = tape.is_some(); replay_row_with(row, &defaults, lines, tape); + // Said at load, not after a walk: a row the venue cannot serve is not + // "missing" — it would only ever come back refused. The fetch job's own + // path (`replay_row` after a walk) is NOT given this: its retries and its + // continuation read `Missing`, and its refusal is the walk's own word. + // Nor is a row whose held query went unanswered: the tile store was not + // read for it, so "cannot be fetched" would be said of a store never asked. + if answered && row.tape == TapeStatus::Missing { + if let Some(address) = row.address.as_ref() { + row.tape = unservable_status(address, &row.deal, now_ms) + .unwrap_or(TapeStatus::Missing); + } + } } // Rows the job answered while the batch was read: read again, each behind // whatever walk is running. A row the job answers during THIS loop is kept @@ -330,6 +348,13 @@ impl AnalyticsView { // The rows still missing are what the user is looking at: a running batch // takes them next. this.ticks_prioritize_visible(); + // With the autoload on, the rows of THIS table the venue can still serve go to + // the fetch without a press: the switch is the consent to spend the budget, + // and the startup pass covers only its own horizon (30 days, every core) — + // a wider period on the table would otherwise sit behind a button. + if moon_core::market::trade_replay::tape_autoload() { + this.ticks_fetch_missing(cx); + } // The replayable set may have changed under the variant columns: rescore them. this.arm_ticks_variants(cx); cx.notify(); @@ -484,6 +509,23 @@ pub(super) fn held_tape(address: &RowAddress, deal: &Deal) -> Option { Some((answer.ticks, answer.covered, spans)) } +/// Why a fetch of a row the terminal holds no tape for could only come back refused, said at +/// load rather than after a walk: no public route for the venue (the worker would serve such a +/// stage from the tile store alone, which the held query just found empty), or a window older +/// than the route's retention. `None` where the venue could serve it. The same rule the fetch +/// job and the startup autoload apply (`inside_retention`), asked here so the row does not +/// read as fetchable — and is not queued — when it is not. +fn unservable_status(address: &RowAddress, deal: &Deal, now_ms: i64) -> Option { + let Some(route) = trade_route(address.venue) else { + return Some(TapeStatus::Refused(TickStatus::NoRoute)); + }; + let window = replay_window_ms(deal.buy_ms, deal.close_ms, model_margin_ms())?; + let retention_ms = route.retention_ms()?; + (!inside_retention(route, window, now_ms)).then_some(TapeStatus::Refused( + TickStatus::OutOfRetention { retention_ms }, + )) +} + /// Run the model on one row, from what the worker holds — asked here, one query; a row /// without an address is left as it is. pub(super) fn replay_row(row: &mut DealRow, defaults: &HashMap, lines: ArchivedLines) { @@ -504,6 +546,7 @@ pub(super) fn replay_row_with( ) { row.ticks = None; row.entry_start = lines.entry_start; + row.held = None; let Some(address) = row.address.clone() else { return; }; @@ -512,6 +555,12 @@ pub(super) fn replay_row_with( row.verdict = None; return; }; + row.held = covered.hull().map(|(from, to)| { + ( + row.deal.buy_ms.saturating_sub(from).max(0), + to.saturating_sub(row.deal.close_ms).max(0), + ) + }); // Coverage is the worker's own word on what was walked; a quiet run-up with no print in it // is covered all the same, which the tape's first stamp could not tell from a missing one. // What must be covered is the model's own rule (`required_spans`), not the whole margin. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index f9365c6a..78abe02e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -2,8 +2,8 @@ //! tape rather than masked by SQL. //! //! Left, 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. Right: the shared "Fact vs …" matrix (the whole +//! stamps, what came of it, whether the terminal holds its tape, and whether the model +//! reproduces the fact; a double-click opens the trade window on it. Right: the shared "Fact vs …" matrix (the whole //! scope, the replayable subset captioned with the ✓ shares, the variant columns), and the //! parameter grid with the strategies' values, the two variant columns and the search row. //! @@ -27,6 +27,7 @@ use crate::design; use crate::design::{moon, moon_alpha}; use columns::*; pub(in crate::analytics::tuner) use fetch::strategy_field_defaults; +use moon_core::market::trade_replay::TickStatus; use state::SuggState; use state::{DealRow, TapeStatus}; @@ -109,7 +110,7 @@ impl AnalyticsView { let order = view.ticks.order.as_ref()?; let row = view.ticks.data.data()?.rows.get(*order.order.get(ix)?)?; - Some(deal_row(row, p, scale, row_h, zone, app)) + Some(deal_row(row, weak.clone(), p, scale, row_h, zone, app)) }) .unwrap_or_else(|| div().into_any_element()) }) @@ -127,9 +128,6 @@ impl AnalyticsView { // flight at all; a bare "N/M" reads as stuck in both cases. let progress = fetch::job::progress(); let fetch_active = progress.active; - // Rows of THIS table a running batch does not have — the autoload's, or one left by a - // previous window: a second button adds them, while the first stays the stop. - let addable = self.ticks_fetch_addable(); // A batch this window did not start — the startup autoload, or one left by a previous // window — is listened to from the first paint that finds it running, so its answers // land in the table and the button keeps counting. Idempotent: one listener per view @@ -137,11 +135,9 @@ impl AnalyticsView { if fetch_active { self.attach_fetch_listener(cx); } - let fetch_label = if !fetch_active && self.ticks.tape_reading { - t!("analytics.ticks.fetch_reading").to_string() - } else if !fetch_active { - t!("analytics.ticks.fetch_btn").to_string() - } else if !progress.in_flight.is_empty() { + // The button is only the switch — "fetch" or "stop"; what the batch is doing goes into + // the caption beside it, where the sample's coverage sits when nothing runs. + let caption = if fetch_active && !progress.in_flight.is_empty() { // Every market a request is out for, in the order they went out: the walks run in // parallel across venues, and one name would read as one request. let markets: Vec<&str> = progress @@ -156,13 +152,22 @@ impl AnalyticsView { market = markets.join(" · ") ) .to_string() - } else { + } else if fetch_active { t!( "analytics.ticks.fetch_waiting", done = progress.done, total = progress.total ) .to_string() + } else if self.ticks.tape_reading { + t!("analytics.ticks.fetch_reading").to_string() + } else { + coverage_caption(covered, total, without_ms, left_out.1, left_out.2) + }; + let fetch_label = if fetch_active { + t!("analytics.ticks.fetch_stop").to_string() + } else { + t!("analytics.ticks.fetch_btn").to_string() }; v_flex() .w_full() @@ -199,31 +204,16 @@ impl AnalyticsView { .child(scope), ) // "N with tape of M · K without stamps": the honest size of the sample, - // with the service rows and the switch's leftovers when there are any. + // with the service rows and the switch's leftovers when there are any — or, + // while a batch runs, how far it is and which markets it is on. .child( div() .flex_none() .font_family(design::ui_font()) .text_size(design::t_caption(cx)) .text_color(moon(p.text_muted)) - .child(coverage_caption( - covered, total, without_ms, left_out.1, left_out.2, - )), + .child(caption), ) - .when(fetch_active && addable > 0, |el| { - el.child( - div().font_family(design::ui_font()).child( - MoonButton::new("an-ticks-fetch-add") - .variant(MoonButtonVariant::Soft) - .label(t!("analytics.ticks.fetch_add", n = addable).to_string()) - .on_click(cx.listener(move |this, _, _, cx| { - this.ticks_fetch_missing(cx); - cx.notify(); - })) - .render(), - ), - ) - }) .when(fetchable > 0 || fetch_active, |el| { el.child( div().font_family(design::ui_font()).child( @@ -234,6 +224,10 @@ impl AnalyticsView { MoonButtonVariant::Soft }) .label(fetch_label) + // While the tape stage reads, a press would queue rows the + // tiles already hold (`ticks_fetch_missing` waits for the + // fold), so the switch waits too. + .disabled(!fetch_active && self.ticks.tape_reading) .on_click(cx.listener(move |this, _, _, cx| { if fetch_active { this.ticks_fetch_stop(cx); @@ -253,6 +247,51 @@ impl AnalyticsView { .into_any_element() } + /// Open the trade window on one deal of the table — the same opener a Report row uses, so + /// the two lists cannot disagree about what a trade is. + /// + /// Silent when the row has no address (its core is offline, or the coin resolves to no + /// market): the tape dot at the row's left edge already says so, and a double-click has + /// nowhere to put a reason. + /// + /// Args: + /// report_uid: The row's `ReportUID` — the core's own key for the trade, not the replica's row id. + /// cx: View context. + fn open_deal_window(&mut self, report_uid: i64, cx: &mut Context) { + let Some(row) = self + .ticks + .data + .data() + .and_then(|d| d.rows.iter().find(|r| r.deal.report_uid == report_uid)) + else { + return; + }; + let Some(address) = row.address.as_ref() else { + return; + }; + let q = self.query(); + // The window's neighbours are the core's other trades of this coin over the axis's + // period — the Query's bounds are true UTC and `to` is exclusive, as the filter's + // `date_to` is inclusive; one second either way on the neighbours is not a trade lost. + let filter = moon_core::db::ReportFilter { + core_uids: vec![row.deal.core_uid], + date_from: (q.from >= 0).then_some(q.from), + date_to: Some(q.to), + axis: q.axis.clone(), + ..moon_core::db::ReportFilter::default() + }; + // By the core's ReportUID, not the replica's row id: the two are different counters, and + // `reportuid` is the one the deal table is keyed by. + let target = crate::trade_window::open_record::RecordTarget { + core: row.deal.core_uid, + coin: row.deal.coin.clone(), + record: crate::trade_window::open_record::RecordKey::ReportUid(report_uid), + market: address.market.clone(), + filter, + }; + crate::trade_window::open_record::open_trade_record(&self.backend, q.axis, target, cx); + } + /// The table's heading row: every column sortable, the arrow on the active one. fn deal_header(&self, p: MoonPalette, cx: &Context) -> impl IntoElement + use<> { let scale = design::font_scale(cx); @@ -309,7 +348,7 @@ impl AnalyticsView { .children(DEAL_COLS.iter().map(|c| { sortable( SharedString::from(format!("an-ticks-hdr-{}", c.key)), - t!(c.label).to_string(), + column_title(c), c.key, Some(c), ) @@ -339,7 +378,7 @@ impl AnalyticsView { /// both groups — the model's own account of itself), then the variant columns, each over /// the replayable rows and captioned with how many. fn ticks_kpi(&self, p: MoonPalette, cx: &Context) -> AnyElement { - let (covered, total, entry, exit, replayable) = self + let (covered, total, entry, exit, replayable, horizon) = self .ticks .data .data() @@ -350,6 +389,7 @@ impl AnalyticsView { d.entry_share, d.exit_share, d.replayable().count(), + d.exit_horizon_ms(), ) }) .unwrap_or_default(); @@ -360,16 +400,22 @@ impl AnalyticsView { format!("{:.0} %", hits as f64 / n as f64 * 100.0) } }; + // The exit horizon every variant and the search are judged on: the shortest trail the + // replayable rows hold past their close (`prepared_deals`). + let mut subset_sub = t!( + "analytics.ticks.subset_sub", + n = covered, + m = total, + entry = share(entry), + exit = share(exit) + ) + .to_string(); + if let Some(horizon) = horizon { + subset_sub.push_str(&t!("analytics.ticks.horizon", h = duration_text(horizon))); + } let mut labels = vec![VarLabel::with_sub( t!("analytics.ticks.subset").to_string(), - t!( - "analytics.ticks.subset_sub", - n = covered, - m = total, - entry = share(entry), - exit = share(exit) - ) - .to_string(), + subset_sub, )]; // The matrix reads one vector: `[fact, subset]` from the load, then the variants that // were scored. An untouched variant is not a column. @@ -560,6 +606,18 @@ impl AnalyticsView { } } +/// The heading of one column. The profit column names its unit — the cells are bare numbers, +/// and `Deal::profit` is USDT whatever the scope's own quote or metric (the ticker is +/// language-neutral, see locales/README.md). +fn column_title(col: &DealCol) -> String { + let title = t!(col.label).to_string(); + if col.key == COL_PROFIT { + format!("{title}, USDT") + } else { + title + } +} + /// Height of one deal row, in base px — the single pitch the list and the row share. fn deal_row_h(cx: &App) -> f32 { design::fit_h_value(cx, 24.0, 14.0, 5.0) @@ -584,12 +642,12 @@ fn duration_text(ms: i64) -> String { } } -/// A delta cell: signed, one decimal, dimmed at zero. -fn delta_text(v: f64) -> String { - if v == 0.0 { - "—".to_string() - } else { - format!("{v:+.1}") +/// The tape the terminal holds around a trade, as "lead/trail" — what lies before the entry +/// and past the exit; a dash when nothing is held. +fn held_text(held: Option<(i64, i64)>) -> String { + match held { + Some((lead, trail)) => format!("{}/{}", duration_text(lead), duration_text(trail)), + None => "—".to_string(), } } @@ -600,6 +658,19 @@ fn tape_mark(tape: TapeStatus) -> (&'static str, String) { TapeStatus::Missing => ("○", t!("analytics.ticks.tape_missing").to_string()), TapeStatus::Fetching => ("…", t!("analytics.ticks.tape_fetching").to_string()), TapeStatus::NoAddress => ("·", t!("analytics.ticks.tape_no_address").to_string()), + // The two refusals said at load, for every row the venue cannot serve, in words; the + // rest come back from a walk and name the venue's own answer. + TapeStatus::Refused(TickStatus::NoRoute) => { + ("✕", t!("analytics.ticks.tape_no_route").to_string()) + } + TapeStatus::Refused(TickStatus::OutOfRetention { retention_ms }) => ( + "✕", + t!( + "analytics.ticks.tape_retention", + hours = retention_ms / 3_600_000 + ) + .to_string(), + ), TapeStatus::Refused(status) => ( "✕", t!( @@ -698,9 +769,10 @@ fn model_mark(row: &DealRow) -> (String, String) { ) } -/// One deal row. +/// One deal row. A double-click opens the trade window on it, as a Report row does. fn deal_row( row: &DealRow, + view: WeakEntity, p: MoonPalette, scale: f32, row_h: f32, @@ -761,8 +833,6 @@ fn deal_row( for col in DEAL_COLS { let (value, color, tip) = match col.key { COL_TIME => (hms(d.buy_ms, zone), text, None), - COL_BUY => (moon_core::util::fmt::adaptive(d.buy_price), text, None), - COL_SELL => (moon_core::util::fmt::adaptive(d.sell_price), text, None), COL_RESULT => ( format!("{result:+.2}"), if result > 0.0 { @@ -774,12 +844,34 @@ fn deal_row( }, None, ), + // A dash where the scope's money cannot be valued in USDT (`Deal::profit`). + COL_PROFIT => match d.profit { + Some(profit) => ( + super::super::summary::fmt_signed_plain(profit), + if profit > 0.0 { + p.green + } else if profit < 0.0 { + p.red + } else { + p.text_muted + }, + None, + ), + None => ("—".to_string(), p.text_muted, None), + }, COL_DURATION => (duration_text(d.close_ms - d.buy_ms), p.text_muted, None), - COL_D5S => (delta_text(d.deltas.d5s), p.text_muted, None), - COL_D1M => (delta_text(d.deltas.d1m), p.text_muted, None), - COL_D1H => (delta_text(d.deltas.d1h), p.text_muted, None), - COL_DMARK => (delta_text(d.deltas.dmark), p.text_muted, None), - COL_PRICEBUG => (delta_text(d.deltas.pricebug), p.text_muted, None), + COL_HELD => ( + held_text(row.held), + p.text_muted, + row.held.map(|(lead, trail)| { + t!( + "analytics.ticks.held_tip", + lead = duration_text(lead), + trail = duration_text(trail) + ) + .to_string() + }), + ), COL_REASON => ( d.sell_reason.clone(), p.text_muted, @@ -807,6 +899,15 @@ fn deal_row( }; el = el.child(cell(col, value, color, tip)); } - el = el.hover(move |s| s.bg(moon_alpha(p.panel_high, 0.9))); + let uid = d.report_uid; + el = el + .hover(move |s| s.bg(moon_alpha(p.panel_high, 0.9))) + .on_click(move |ev: &ClickEvent, _window, app| { + if ev.click_count() < 2 { + return; + } + // The view may already be gone; a dropped window is not an error here. + let _ = view.update(app, |this, cx| this.open_deal_window(uid, cx)); + }); el.into_any_element() } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs index 8dc36a43..790f4433 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs @@ -87,15 +87,15 @@ fn sort_indices(rows: &[DealRow], order: &mut [usize], key: &str, desc: bool) { let c = rows[a].deal.coin.cmp(&rows[b].deal.coin); if desc { c.reverse() } else { c } }), - COL_BUY => by_f64(&|r| r.deal.buy_price, order), - COL_SELL => by_f64(&|r| r.deal.sell_price, order), COL_RESULT => by_f64(&result_pct, order), + // Unpriced sorts as the smallest. + COL_PROFIT => by_f64(&|r| r.deal.profit.unwrap_or(f64::MIN), order), COL_DURATION => by_f64(&|r| (r.deal.close_ms - r.deal.buy_ms) as f64, order), - COL_D5S => by_f64(&|r| r.deal.deltas.d5s, order), - COL_D1M => by_f64(&|r| r.deal.deltas.d1m, order), - COL_D1H => by_f64(&|r| r.deal.deltas.d1h, order), - COL_DMARK => by_f64(&|r| r.deal.deltas.dmark, order), - COL_PRICEBUG => by_f64(&|r| r.deal.deltas.pricebug, order), + // By the trail — the half the exit horizon is taken from; nothing held is the shortest. + COL_HELD => by_f64( + &|r| r.held.map(|(_, trail)| trail as f64).unwrap_or(-1.0), + order, + ), COL_REASON => order.sort_by(|&a, &b| { let c = rows[a].deal.sell_reason.cmp(&rows[b].deal.sell_reason); if desc { c.reverse() } else { c } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index e4f839ed..ad6b14ba 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -1,4 +1,4 @@ -use super::super::columns::{COL_MODEL, COL_RESULT, COL_TAPE, COL_TIME}; +use super::super::columns::{COL_HELD, COL_MODEL, COL_PROFIT, COL_RESULT, COL_TAPE, COL_TIME}; use super::super::state::{DealRow, TapeStatus, TicksData, TicksState}; use super::{order_for, result_pct}; use moon_core::db::tuner::ticks::{Deal, Deltas, Verdict}; @@ -19,6 +19,7 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { is_short: short, sell_reason: String::new(), fact_pnl: 0.0, + profit: None, deltas: Deltas::default(), tick: None, } @@ -37,7 +38,7 @@ fn verdict(entry: Option, exit: Option) -> Verdict { } fn state() -> TicksState { - let rows = vec![ + let mut rows = vec![ DealRow { deal: deal(1, 3_000, 100.0, 101.0, false), tape: TapeStatus::Missing, @@ -45,6 +46,7 @@ fn state() -> TicksState { address: None, ticks: None, entry_start: None, + held: None, }, DealRow { deal: deal(2, 1_000, 100.0, 99.0, false), @@ -53,6 +55,7 @@ fn state() -> TicksState { address: None, ticks: None, entry_start: None, + held: None, }, DealRow { deal: deal(3, 2_000, 100.0, 99.0, true), @@ -61,8 +64,17 @@ fn state() -> TicksState { address: None, ticks: None, entry_start: None, + held: None, }, ]; + // Money as the core wrote it, deliberately NOT the sign of the price move: the profit sort + // must read `profit`, not derive it from the prices. + for (row, profit) in rows.iter_mut().zip([-3.0, 12.5, 0.0]) { + row.deal.profit = Some(profit); + } + // What the terminal holds around each trade, `(lead, trail)`: the third row has nothing. + rows[0].held = Some((60_000, 30_000)); + rows[1].held = Some((60_000, 60_000)); let mut state = TicksState::default(); state.data.apply(Ok(TicksData { rows, @@ -104,6 +116,27 @@ fn a_short_result_is_signed_from_its_own_side() { assert!(top == 1 || top == 3); } +/// The profit column sorts by the row's money, whatever the prices say. +#[test] +fn profit_sorts_by_the_rows_money() { + let mut state = state(); + state.sort = Some((COL_PROFIT.to_string(), true)); + assert_eq!(uids(&mut state), [2, 3, 1]); + state.sort = Some((COL_PROFIT.to_string(), false)); + assert_eq!(uids(&mut state), [1, 3, 2]); +} + +/// The held column sorts by the trail the terminal holds past the exit — what the exit +/// horizon of the sample is taken from; a row with nothing held sorts as the shortest. +#[test] +fn the_held_tape_sorts_by_its_trail() { + let mut state = state(); + state.sort = Some((COL_HELD.to_string(), true)); + assert_eq!(uids(&mut state), [2, 1, 3]); + state.sort = Some((COL_HELD.to_string(), false)); + assert_eq!(uids(&mut state), [3, 1, 2]); +} + #[test] fn tape_and_model_sort_by_rank_and_the_cache_follows_the_sort() { let mut state = state(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index fc30f6b0..a2959bc9 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -63,12 +63,20 @@ pub(in crate::analytics::tuner) struct DealRow { pub(in crate::analytics::tuner) ticks: Option>, /// The archived first point of the entry line, when the archive holds it. pub(in crate::analytics::tuner) entry_start: Option<(i64, f64)>, + /// What the terminal HOLDS of the window, as `(lead_ms, trail_ms)`: how far before the + /// entry and past the exit the held coverage reaches, clipped to what the window asks for + /// (the setting's margin, floored for the model). `None` until the tape stage answered, or + /// when it holds nothing. The table's "tape" column; the exit horizon of the sample is the + /// shortest trail among the replayable rows. + pub(in crate::analytics::tuner) held: Option<(i64, i64)>, } /// Where a deal's prints live, as the replay worker keys them, plus what a fetch needs. #[derive(Clone, Debug)] pub(in crate::analytics::tuner) struct RowAddress { pub(in crate::analytics::tuner) core_uid: u64, + /// The venue the core is connected to — decides the public trade route and its retention. + pub(in crate::analytics::tuner) venue: moon_core::venue::Venue, pub(in crate::analytics::tuner) exchange_key: String, pub(in crate::analytics::tuner) market: String, /// The market's price step from the live catalog, when the core reports it. @@ -119,6 +127,16 @@ impl TicksData { .count() } + /// The exit horizon of the replayable sample, in milliseconds — the shortest HELD trail + /// past the close among the rows the variants and the search replay, the same rule as + /// `search::common_horizon_ms` over the same rows (`prepared_deals` hands it each row's + /// `held` trail), or `None` with nothing to replay. + pub(in crate::analytics::tuner) fn exit_horizon_ms(&self) -> Option { + self.replayable() + .map(|r| r.held.map(|(_, trail)| trail.max(0)).unwrap_or(0)) + .min() + } + /// Rows a fetch could still fill: missing, with an address, not already asked. pub(in crate::analytics::tuner) fn fetchable(&self) -> impl Iterator { self.rows @@ -418,8 +436,15 @@ impl TicksState { let slot = &mut data.rows[i]; // The fetch job may have covered the row while this batch was being read, and // said so through the listener; a read from before its walk must not undo that. - // Coverage only grows between reloads, so the fresher word is the covered one. - if slot.tape == TapeStatus::Covered && answer.tape == TapeStatus::Missing { + // Coverage only grows between reloads, so the fresher word is the covered one — + // and a refusal the walk itself gave outranks a plain "missing" read from before + // it, or the row would read as fetchable again and be queued once more. + let stale = match (slot.tape, answer.tape) { + (TapeStatus::Covered, other) => other != TapeStatus::Covered, + (TapeStatus::Refused(_), TapeStatus::Missing) => true, + _ => false, + }; + if stale { continue; } slot.tape = answer.tape; @@ -427,6 +452,7 @@ impl TicksState { slot.deal.tick = answer.deal.tick; slot.ticks = answer.ticks; slot.entry_start = answer.entry_start; + slot.held = answer.held; } data.retain_within_cap(); data.refresh_summary(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index de6e9ef0..7581f14d 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -19,7 +19,9 @@ use crate::analytics::bg::ReadLane; use moon_core::db::tuner::threshold_search::SearchHandle; use moon_core::db::tuner::ticks::mshot::DEFAULT_LATENCY_MS; use moon_core::db::tuner::ticks::params::ParamGroup; -use moon_core::db::tuner::ticks::search::{PreparedDeal, SearchParams, suggest, variant_tally}; +use moon_core::db::tuner::ticks::search::{ + PreparedDeal, SearchParams, clip_to_horizon, common_horizon_ms, suggest, variant_tally, +}; use moon_core::db::tuner::ticks::stats_of; /// How long a burst of cell edits may keep coalescing before the columns are rescored. @@ -48,9 +50,12 @@ fn base_of(now: &HashMap) -> HashMap { } impl AnalyticsView { - /// The replayable rows as the search and the columns take them. + /// The replayable rows as the search and the columns take them — every tape cut at the + /// sample's one exit horizon (`clip_to_horizon`), so no variant is judged on more tape + /// than another. fn prepared_deals(&self) -> Vec { - self.ticks + let mut deals: Vec = self + .ticks .data .data() .map(|d| { @@ -60,11 +65,16 @@ impl AnalyticsView { deal: row.deal.clone(), ticks: row.ticks.clone()?, entry_start: row.entry_start, + trail_ms: row.held.map(|(_, trail)| trail).unwrap_or(0), }) }) .collect() }) - .unwrap_or_default() + .unwrap_or_default(); + if let Some(horizon_ms) = common_horizon_ms(&deals) { + clip_to_horizon(&mut deals, horizon_ms); + } + deals } /// Arm a debounced rescore of the variant columns — every edit of a cell, every row that diff --git a/crates/moon-ui-gpui/src/trade_window/open_record.rs b/crates/moon-ui-gpui/src/trade_window/open_record.rs index d1cf7137..911f6370 100644 --- a/crates/moon-ui-gpui/src/trade_window/open_record.rs +++ b/crates/moon-ui-gpui/src/trade_window/open_record.rs @@ -51,10 +51,6 @@ pub(crate) enum RecordKey { /// [`ChartTradeRecord::record_id`]. RecordId(i64), /// [`ChartTradeRecord::report_uid`]; a row replicated without one can never match. - #[expect( - dead_code, - reason = "the tuner's Entry/Exit axis, its consumer, lands separately" - )] ReportUid(i64), } @@ -117,36 +113,6 @@ pub(crate) fn open_trade_record( cx: &mut App, ) { let backend = backend.clone(); - resolve_trade_record(axis, target, cx, move |seed, cx| { - let Some(seed) = seed else { - return; - }; - let super::TradeSeed { - record, - meta, - history, - market, - stamps, - } = seed; - super::open_trade_window(&backend, record, meta, history, market, stamps, cx); - }); -} - -/// Read a target's trade off the replica, off the UI thread, and hand it to `done` on it — the -/// one read both the window and the tuner's trade pane are built from. -/// -/// Args: -/// axis: Time axis the captions render on; see [`open_trade_record`]. -/// target: The already-resolved row. -/// cx: Application context. -/// done: Called once on the UI thread with the resolved trade, or `None` when the replica -/// could not resolve it. -pub(crate) fn resolve_trade_record( - axis: db::ReportAxis, - target: RecordTarget, - cx: &mut App, - done: impl FnOnce(Option, &mut App) + 'static, -) { let RecordTarget { core, coin, @@ -162,20 +128,14 @@ pub(crate) fn resolve_trade_record( .spawn(async move { load_trade(core, coin, record, filter) }) .await; cx.update(|cx| { - let seed = found.map(|(record, meta, history)| { - let stamps = ( - stamp(&axis, core, record.buy_stamp()), - stamp(&axis, core, record.close_stamp()), - ); - super::TradeSeed { - record, - meta, - history, - market, - stamps, - } - }); - done(seed, cx); + let Some((record, meta, history)) = found else { + return; + }; + let stamps = ( + stamp(&axis, core, record.buy_stamp()), + stamp(&axis, core, record.close_stamp()), + ); + super::open_trade_window(&backend, record, meta, history, market, stamps, cx); }); }) .detach(); diff --git a/locales/analytics.yml b/locales/analytics.yml index b5c21357..eec97cb3 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1728,62 +1728,46 @@ analytics.ticks.fetch_btn: ru: "Прогрузить трейды" en: "Fetch trades" es: "Cargar trades" -analytics.ticks.fetch_add: - ru: "+ ещё %{n}" - en: "+ %{n} more" - es: "+ %{n} más" +analytics.ticks.fetch_stop: + ru: "Стоп" + en: "Stop" + es: "Parar" analytics.ticks.fetch_reading: ru: "лента читается…" en: "reading the tape…" es: "leyendo la cinta…" analytics.ticks.fetch_progress_at: - ru: "трейды: %{done}/%{total} · %{market}… · стоп" - en: "trades: %{done}/%{total} · %{market}… · stop" - es: "trades: %{done}/%{total} · %{market}… · parar" + ru: "трейды: %{done}/%{total} · %{market}…" + en: "trades: %{done}/%{total} · %{market}…" + es: "trades: %{done}/%{total} · %{market}…" analytics.ticks.fetch_waiting: - ru: "трейды: %{done}/%{total} · ждём биржу · стоп" - en: "trades: %{done}/%{total} · waiting for the venue · stop" - es: "trades: %{done}/%{total} · esperando al exchange · parar" + ru: "трейды: %{done}/%{total} · ждём биржу" + en: "trades: %{done}/%{total} · waiting for the venue" + es: "trades: %{done}/%{total} · esperando al exchange" analytics.ticks.col.time: ru: "вход" en: "entry" es: "entrada" -analytics.ticks.col.buy: - ru: "buy" - en: "buy" - es: "buy" -analytics.ticks.col.sell: - ru: "sell" - en: "sell" - es: "sell" analytics.ticks.col.result: ru: "%" en: "%" es: "%" +analytics.ticks.col.profit: + ru: "прибыль" + en: "profit" + es: "beneficio" analytics.ticks.col.duration: ru: "длит." en: "dur." es: "dur." -analytics.ticks.col.d5s: - ru: "d5s" - en: "d5s" - es: "d5s" -analytics.ticks.col.d1m: - ru: "d1m" - en: "d1m" - es: "d1m" -analytics.ticks.col.d1h: - ru: "d1h" - en: "d1h" - es: "d1h" -analytics.ticks.col.dmark: - ru: "dmark" - en: "dmark" - es: "dmark" -analytics.ticks.col.pricebug: - ru: "pbug" - en: "pbug" - es: "pbug" +analytics.ticks.col.held: + ru: "в базе" + en: "held" + es: "guardado" +analytics.ticks.held_tip: + ru: "Трейды в базе: %{lead} до входа, %{trail} после выхода" + en: "Prints held: %{lead} before the entry, %{trail} past the exit" + es: "Operaciones guardadas: %{lead} antes de la entrada, %{trail} tras la salida" analytics.ticks.col.reason: ru: "причина" en: "reason" @@ -1816,6 +1800,14 @@ analytics.ticks.tape_refused: ru: "Биржа не отдала трейды: %{status}" en: "The venue did not serve the trades: %{status}" es: "El exchange no entregó los trades: %{status}" +analytics.ticks.tape_no_route: + ru: "Биржа не даёт публичной истории трейдов — ленту не скачать" + en: "The venue serves no public trade history — the tape cannot be fetched" + es: "La bolsa no ofrece historial público de operaciones — la cinta no se puede descargar" +analytics.ticks.tape_retention: + ru: "Сделка старше удержания биржи (%{hours} ч) — биржа уже не отдаёт эти трейды" + en: "Older than the venue's retention (%{hours} h) — the venue no longer serves these trades" + es: "Más antigua que la retención de la bolsa (%{hours} h) — la bolsa ya no entrega esas operaciones" analytics.ticks.model_tip: ru: "Вход: отклонение модели от факта %{entry} · Выход: %{exit}" en: "Entry: model vs fact %{entry} · Exit: %{exit}" @@ -1828,6 +1820,10 @@ analytics.ticks.subset_sub: ru: "по %{n} из %{m} · вход ✓ %{entry} · выход ✓ %{exit}" en: "%{n} of %{m} · entry ✓ %{entry} · exit ✓ %{exit}" es: "%{n} de %{m} · entrada ✓ %{entry} · salida ✓ %{exit}" +analytics.ticks.horizon: + ru: " · выход ≤ %{h} после закрытия" + en: " · exit ≤ %{h} past the close" + es: " · salida ≤ %{h} tras el cierre" analytics.ticks.params_title: ru: "Параметры" en: "Parameters" From fdea06051ccf92c44e8a81203b983e26c74724d4 Mon Sep 17 00:00:00 2001 From: guyverino Date: Mon, 21 Sep 2026 10:16:51 +0200 Subject: [PATCH 09/51] fix(trade-replay): stop refusing Gate futures pages the venue served in full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/market/trade_replay/rest/gateio.rs | 70 +------- .../market/trade_replay/rest/gateio/tests.rs | 168 ++---------------- 2 files changed, 20 insertions(+), 218 deletions(-) diff --git a/crates/moon-core/src/market/trade_replay/rest/gateio.rs b/crates/moon-core/src/market/trade_replay/rest/gateio.rs index f9c7f1e4..933c0ffc 100644 --- a/crates/moon-core/src/market/trade_replay/rest/gateio.rs +++ b/crates/moon-core/src/market/trade_replay/rest/gateio.rs @@ -247,28 +247,7 @@ pub(super) fn fetch_trades( true => "contract", false => "currency_pair", }; - // A futures walk is paged back by TIME: the next page ends at the second of the oldest - // row seen (one past it, as `trade_window_seconds` widens every `to`), and the rows of - // that second the page before already took are dropped by id in `parse_futures_trades`. - // A second too dense for a page is drained by `offset` with `from`/`to` pinned to it - // (`TradeCursor::Within`) — the one use of `offset`. One `from` and one `to` in the query, - // decided here: `query` appends, and a second `to` would leave the venue to pick either. - let (from_s, to_s, offset) = match (futures, cursor) { - (true, Some(TradeCursor::Before { boundary_ms, .. })) => { - let (from_s, to_s) = trade_window_seconds(from_ms, boundary_ms.min(to_ms)); - (from_s, to_s, None) - } - ( - true, - Some(TradeCursor::Within { - second_s, offset, .. - }), - ) => (second_s, second_s + 1, Some(offset)), - _ => { - let (from_s, to_s) = trade_window_seconds(from_ms, to_ms); - (from_s, to_s, None) - } - }; + let (from_s, to_s) = trade_window_seconds(from_ms, to_ms); let mut request = agent .get(route.url()) .query(market_param, market) @@ -407,21 +386,9 @@ fn parse_spot_trade_row(row: &Value) -> Option { /// where spot's `docs/Trade.md` types it `str` — and the recorded response agrees. Spot's /// `create_time_ms` is a millisecond string; the two parsers are deliberately not shared. /// -/// A FULL page is NEVER accepted as complete: this endpoint truncates SILENTLY at `limit` with -/// no error, so a full page means "ask again", never "that was all" — see -/// [`super::super::rest::TradePage::next`]'s own doc for why that rule is frozen. -/// -/// # Paged by time and id; one second at a time by `offset` -/// -/// The next page is asked up to the second of this page's OLDEST row ([`TradeCursor::Before`]), -/// so it holds that second again: the rows of it this page took are told apart by trade id — -/// every row at or above the oldest id taken is dropped, and on a continuation page a row -/// with no id is dropped too, or it would be taken again on every page — and the rest are -/// new. A full page that brought NOTHING new is a second holding more prints than a page, -/// which `to` in whole seconds cannot enter: the walk drains that one second by `offset` -/// ([`TradeCursor::Within`]) until a short page, then goes back to time up to the second's -/// own start. A drain page that brought nothing new and is full still moves the offset — the -/// rows behind it are the venue's, and the page budget bounds the walk. +/// A FULL page is ALWAYS treated as incomplete regardless of any other signal: this endpoint +/// truncates SILENTLY at `limit` with no error, so a full page means "ask again", never "that was +/// all" — see [`super::super::rest::TradePage::next`]'s own doc for why that rule is frozen. /// /// Args: /// body: Decoded response. @@ -439,32 +406,9 @@ pub(super) fn parse_futures_trades( let rows = body.as_array().ok_or_else(|| { FetchError::Transient("gate: futures response is not an array".to_string()) })?; - let full = rows.len() >= max_rows; - // Rows the page before already took: at or above the oldest id it held. - let below_id = match cursor { - Some(TradeCursor::Before { below_id, .. } | TradeCursor::Within { below_id, .. }) => { - below_id - } - _ => u64::MAX, - }; - let continuing = matches!( - cursor, - Some(TradeCursor::Before { .. } | TradeCursor::Within { .. }) - ); - let raw_len = rows.len(); - let rows: Vec<&Value> = rows - .iter() - .filter(|row| match futures_row_id(row) { - Some(id) => id < below_id, - None => !continuing, - }) - .collect(); - // The `size: 0` rows a small contract prints between real fills are split off first — the - // rule of `split_no_fill`, over the rows kept; this is the route they were recorded on. - let (fills, empty): (Vec<&Value>, Vec<&Value>) = rows - .iter() - .partition(|row| row.get("size").and_then(cell_number) != Some(0.0)); - let no_fill = empty.len(); + // The `size: 0` rows a small contract prints between real fills are split off first — see + // `split_no_fill`; this is the route they were recorded on. + let (fills, no_fill) = split_no_fill(rows, "size"); let ticks: Vec = fills .iter() .filter_map(|row| parse_futures_trade_row(row)) diff --git a/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs b/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs index b28d000c..0d287493 100644 --- a/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs +++ b/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs @@ -253,22 +253,17 @@ fn gate_futures_trade_falls_back_to_second_timestamps() { /// the next page to a row other than the oldest one it holds, either stops a silently /// truncated page or skips the prints between the two rows. #[test] -fn gate_futures_trade_full_page_continues_from_its_oldest_row() { - let one = serde_json::json!([{"id": 7, "price": "3", "size": 1, "create_time_ms": 10.5}]); - let exact = parse_futures_trades(&one, 1, None).expect("exact page"); - assert_eq!( - exact.next, - Some(TradeCursor::Before { - boundary_ms: 10_500, - below_id: 7 - }) - ); +fn gate_futures_trade_full_page_continues_by_row_count() { + let row = serde_json::json!({"price": "3", "size": 1, "create_time_ms": 10.0}); + let one = serde_json::json!([row]); + let exact = parse_futures_trades(&one, 1, Some(TradeCursor::Offset(10))).expect("exact page"); + assert_eq!(exact.next, Some(TradeCursor::Offset(11))); assert_eq!(exact.ticks.len(), 1); // Newest first, as the venue answers. let two = serde_json::json!([ - {"id": 8, "price": "4", "size": 2, "create_time_ms": 11.0}, - {"id": 7, "price": "3", "size": 1, "create_time_ms": 10.0} + {"price": "3", "size": 1, "create_time_ms": 10.0}, + {"price": "4", "size": 2, "create_time_ms": 11.0} ]); let over = parse_futures_trades(&two, 1, None).expect("over-full"); assert_eq!( @@ -400,154 +395,17 @@ fn gate_futures_trades_skip_a_zero_size_row_without_refusing_the_page() { } /// `rest/gateio.rs:parse_futures_trades` on a FULL page of nothing but `size: 0` rows: the -/// route never accepts a full page as complete, so the cursor must still move onto the page's -/// oldest row — a dead stretch on a small contract is walked through, not refused and not -/// mistaken for the end of the tape. +/// route never accepts a full page as complete, so the cursor must still advance by the +/// venue's row count — a dead stretch on a small contract is walked through, not refused and +/// not mistaken for the end of the tape. #[test] fn gate_futures_page_of_only_zero_size_rows_is_empty_and_still_pages_on() { let mut body = fixture("futures_trades_zero_size"); for row in body.as_array_mut().expect("array") { row["size"] = serde_json::json!(0); } - let rows = body.as_array().expect("array"); - let oldest_id = rows - .iter() - .map(|r| r["id"].as_u64().unwrap()) - .min() - .unwrap(); - let page = parse_futures_trades( - &body, - 6, - Some(TradeCursor::Before { - boundary_ms: i64::MAX, - below_id: u64::MAX, - }), - ) - .expect("a page of zero-size rows parses"); + let page = parse_futures_trades(&body, 6, Some(TradeCursor::Offset(6))) + .expect("a page of zero-size rows parses"); assert!(page.ticks.is_empty()); - assert!( - matches!(page.next, Some(TradeCursor::Before { below_id, .. }) if below_id == oldest_id) - ); -} - -/// A full page hands back a `Before` cursor at its OLDEST row (time and id), and the next page -/// — asked up to that row's second, so it holds that second again — drops every row at or -/// above that id and keeps the rest. Three rows against a cap of three is a full page. -#[test] -fn gate_futures_full_page_pages_back_by_time_and_drops_the_rows_already_taken() { - let body = fixture("futures_trades"); - let page = parse_futures_trades(&body, 3, None).expect("full page parses"); - assert_eq!(page.ticks.len(), 3); - let Some(TradeCursor::Before { - boundary_ms, - below_id, - }) = page.next - else { - panic!("a full page pages on: {:?}", page.next); - }; - assert_eq!(boundary_ms, 1_789_726_950_929, "the oldest row's stamp"); - assert_eq!(below_id, 29202, "the oldest row's id"); - // The next page, as the venue answers `to=1789726951`: the two rows of that second again - // (ids 29203, 29202 — already held) plus one older row. - let next_body = serde_json::json!([ - {"id": 29203, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, - {"id": 29202, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, - {"id": 29201, "contract": "CATE_USDT", "create_time": 1789726948.100, "create_time_ms": 1789726948.100, "size": -2, "price": "0.09100"} - ]); - let next = parse_futures_trades(&next_body, 3, page.next).expect("next page parses"); - assert_eq!( - next.ticks.len(), - 1, - "only the row below the id already held" - ); - assert_eq!(next.ticks[0].time_ms, 1_789_726_948_100.0); - assert!( - matches!( - next.next, - Some(TradeCursor::Before { - below_id: 29201, - boundary_ms: 1_789_726_948_100 - }) - ), - "{:?}", - next.next - ); - // A full page whose every row is already held is a second denser than a page: the walk - // drains that second by offset, from its start. - let dense = serde_json::json!([ - {"id": 29203, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, - {"id": 29202, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, - {"id": 29204, "contract": "CATE_USDT", "create_time": 1789726954.306, "create_time_ms": 1789726954.306, "size": -1, "price": "0.09058"} - ]); - let drain = parse_futures_trades(&dense, 3, page.next).expect("parses"); - assert!(drain.ticks.is_empty()); - assert_eq!( - drain.next, - Some(TradeCursor::Within { - second_s: 1_789_726_950, - offset: 0, - below_id: 29202, - low_id: 29202 - }) - ); - // A full drain page moves the offset by the venue's row count and keeps only the new - // rows; the boundary id follows the oldest new row. - let drain_page = serde_json::json!([ - {"id": 29202, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, - {"id": 29200, "contract": "CATE_USDT", "create_time": 1789726950.500, "create_time_ms": 1789726950.500, "size": 3, "price": "0.09101"}, - {"id": 29199, "contract": "CATE_USDT", "create_time": 1789726950.400, "create_time_ms": 1789726950.400, "size": 3, "price": "0.09101"} - ]); - let drained = parse_futures_trades(&drain_page, 3, drain.next).expect("parses"); - assert_eq!(drained.ticks.len(), 2); - assert_eq!( - drained.next, - Some(TradeCursor::Within { - second_s: 1_789_726_950, - offset: 3, - below_id: 29202, - low_id: 29199 - }), - "the boundary stays, the low id follows the drain" - ); - // Oldest-first inside the second would not lose a row: the boundary does not follow. - let ascending = serde_json::json!([ - {"id": 29195, "contract": "CATE_USDT", "create_time": 1789726950.050, "create_time_ms": 1789726950.050, "size": 1, "price": "0.09100"}, - {"id": 29196, "contract": "CATE_USDT", "create_time": 1789726950.060, "create_time_ms": 1789726950.060, "size": 1, "price": "0.09100"}, - {"id": 29197, "contract": "CATE_USDT", "create_time": 1789726950.070, "create_time_ms": 1789726950.070, "size": 1, "price": "0.09100"} - ]); - let asc = parse_futures_trades(&ascending, 3, drained.next).expect("parses"); - assert_eq!(asc.ticks.len(), 3); - // A short drain page ends the second: back to time, up to the second's own start, so the - // next `to` is the second itself and the rows of it are not asked a third time. - let tail = serde_json::json!([ - {"id": 29198, "contract": "CATE_USDT", "create_time": 1789726950.100, "create_time_ms": 1789726950.100, "size": 1, "price": "0.09100"} - ]); - let ended = parse_futures_trades(&tail, 3, drained.next).expect("parses"); - assert_eq!(ended.ticks.len(), 1); - assert_eq!( - ended.next, - Some(TradeCursor::Before { - boundary_ms: 1_789_726_949_999, - below_id: 29198 - }) - ); - assert_eq!(trade_window_seconds(0, 1_789_726_949_999).1, 1_789_726_950); - // On a continuation page a row without an id is dropped, not taken again. - let no_id = serde_json::json!([ - {"contract": "CATE_USDT", "create_time": 1789726948.100, "create_time_ms": 1789726948.100, "size": -2, "price": "0.09100"} - ]); - let short = parse_futures_trades(&no_id, 3, page.next).expect("parses"); - assert!(short.ticks.is_empty()); - assert_eq!(short.next, None); - let first = parse_futures_trades(&no_id, 3, None).expect("parses"); - assert_eq!(first.ticks.len(), 1, "a first page keeps it"); -} - -/// `rest/gateio.rs:fetch_trades` sends the cursor's boundary as `to`, one second past the -/// boundary's own second, and never an `offset`. -#[test] -fn gate_futures_before_cursor_moves_to_onto_the_boundary_second() { - // `trade_window_seconds` is the one rule for `to`; the cursor reuses it on its boundary. - let (_, to) = trade_window_seconds(0, 1_789_726_950_929); - assert_eq!(to, 1_789_726_951); + assert_eq!(page.next, Some(TradeCursor::Offset(12))); } From 257969ad8f1dde8f55d231f9a39193ae315e8fa7 Mon Sep 17 00:00:00 2001 From: guyverino Date: Mon, 21 Sep 2026 10:17:27 +0200 Subject: [PATCH 10/51] feat(storage): 5 s trade margin by default, a long position from 5 minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/config/storage.rs | 14 ++--- crates/moon-core/src/config/storage/tests.rs | 6 ++- .../moon-core/src/market/trade_replay/mod.rs | 10 ++-- .../src/market/trade_replay/settings.rs | 4 +- .../src/market/trade_replay/tests.rs | 9 ++-- .../src/market/trade_replay/worker.rs | 5 +- crates/moon-core/src/session/lifecycle.rs | 12 ++--- .../src/analytics/tuner/ticks/fetch/job.rs | 8 +-- .../analytics/tuner/ticks/fetch/job/tests.rs | 51 ++++++++++++------- crates/moon-ui-gpui/src/settings/storage.rs | 2 +- locales/storage.yml | 6 +-- 11 files changed, 74 insertions(+), 53 deletions(-) diff --git a/crates/moon-core/src/config/storage.rs b/crates/moon-core/src/config/storage.rs index f1c5b054..821add6a 100644 --- a/crates/moon-core/src/config/storage.rs +++ b/crates/moon-core/src/config/storage.rs @@ -88,16 +88,18 @@ pub const LONG_POSITION_MIN_RANGE: std::ops::RangeInclusive = 1..=120; pub const DEFAULT_TRADES_MAX_MB: u32 = 256; /// The values [`TradeReplayStoreCfg::margin_s`] may take, ascending: the Storage tab steps -/// through this list rather than by a fixed amount, so the short end is fine-grained (10 s for a -/// scalp) and the long end coarse. The floor is 10 s — "the position alone" is gone: a window +/// through this list rather than by a fixed amount, so the short end is fine-grained (5 s for a +/// scalp) and the long end coarse. The floor is 5 s — "the position alone" is gone: a window /// with no prints outside the position has nothing to show around the entry. The ceiling is two /// hours: the bar context after an exit is two hours at least, and prints past the bars would /// have nowhere to draw. -pub const TRADE_MARGIN_STEPS_S: &[u32] = &[10, 30, 60, 180, 300, 600, 900, 1800, 3600, 7200]; +pub const TRADE_MARGIN_STEPS_S: &[u32] = &[5, 10, 30, 60, 180, 300, 600, 900, 1800, 3600, 7200]; -/// Default seconds of prints around a trade, per end — 15 minutes (the developer's call, -/// 2026-09-20). -pub const DEFAULT_TRADE_MARGIN_S: u32 = 900; +/// Default seconds of prints around a trade, per end — 5 s (the developer's call, 2026-09-21; +/// 15 minutes before that). The tuner's model window and the close-time capture both pad this +/// to at least the model's own run-up and tail (`trade_replay::model_margin_ms`), so the short +/// default shapes the chart's windows, not what the tuner is served. +pub const DEFAULT_TRADE_MARGIN_S: u32 = 5; /// Ceiling on [`TradeReplayStoreCfg::margin_s`] — the last of [`TRADE_MARGIN_STEPS_S`]. pub const MAX_TRADE_MARGIN_S: u32 = 7200; diff --git a/crates/moon-core/src/config/storage/tests.rs b/crates/moon-core/src/config/storage/tests.rs index 4bca07c6..0127d733 100644 --- a/crates/moon-core/src/config/storage/tests.rs +++ b/crates/moon-core/src/config/storage/tests.rs @@ -86,7 +86,9 @@ fn snap_and_step_walk_the_step_list() { TRADE_MARGIN_STEPS_S.last().copied(), Some(MAX_TRADE_MARGIN_S) ); - assert_eq!(snap_trade_margin_s(0), 10); + assert_eq!(snap_trade_margin_s(0), 5); + assert_eq!(snap_trade_margin_s(7), 5, "nearer to 5 than to 10"); + assert_eq!(snap_trade_margin_s(8), 10); assert_eq!(snap_trade_margin_s(19), 10); assert_eq!(snap_trade_margin_s(20), 10, "tie goes to the lower step"); assert_eq!(snap_trade_margin_s(21), 30); @@ -100,7 +102,7 @@ fn snap_and_step_walk_the_step_list() { 7200, "the top absorbs the rest" ); - assert_eq!(step_trade_margin_s(10, -1), 10, "so does the bottom"); + assert_eq!(step_trade_margin_s(5, -1), 5, "so does the bottom"); assert_eq!( step_trade_margin_s(2700, 1), 3600, diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index e0f967a0..fd02abcd 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -85,13 +85,17 @@ const CONTEXT_FRACTION: f64 = 0.5; /// A meaning bound, not a resource one: the page budget already caps what a walk can fetch, but /// on a multi-hour position it burned out ~40 minutes after the entry and the exit came back as /// bars — while at the zoom such a position is viewed at, the chart draws bars for the middle -/// anyway. One hour is the developer's call (2026-09-20): past it the ticks between the ends -/// are a ribbon nobody reads, and what matters is how the entry and the exit printed. +/// anyway. Five minutes is the developer's call (2026-09-21; an hour the day before): past it +/// the ticks between the ends are a ribbon nobody reads, and what matters is how the entry and +/// the exit printed. The tuner's model runs a long position on that two-end tape as it is — +/// also the developer's call, the same day: an exit that really happened in the unwalked +/// middle is a miss in the replay, and the deal table's "held" column shows how far the tape +/// reaches on each side. /// /// Public because the tuner's fetch clusters several trades of one market into one request /// whose open is the first entry and whose close is the last exit: a cluster longer than this /// would be walked as two ends, and the trades in between would go without their tape. -pub const LONG_POSITION_MS: i64 = 60 * MINUTE_MS; +pub const LONG_POSITION_MS: i64 = 5 * MINUTE_MS; /// How far before the entry and past the exit a MODEL's request treats the tape as part of the /// trade itself (walked under the trade budget, never cut short by the normal page ceiling): diff --git a/crates/moon-core/src/market/trade_replay/settings.rs b/crates/moon-core/src/market/trade_replay/settings.rs index 4676efd9..b3a9b277 100644 --- a/crates/moon-core/src/market/trade_replay/settings.rs +++ b/crates/moon-core/src/market/trade_replay/settings.rs @@ -31,8 +31,8 @@ fn init() { }); } -/// The configured margin, in milliseconds — what every new [`super::ReplayWindow`] and every -/// close-time capture is built with. +/// The configured margin, in milliseconds — what every new chart [`super::ReplayWindow`] is +/// built with. A model's window and the close-time capture take [`model_margin_ms`] instead. pub fn margin_ms() -> i64 { init(); i64::from(MARGIN_S.load(Ordering::Relaxed)) * 1_000 diff --git a/crates/moon-core/src/market/trade_replay/tests.rs b/crates/moon-core/src/market/trade_replay/tests.rs index c234d079..fe425412 100644 --- a/crates/moon-core/src/market/trade_replay/tests.rs +++ b/crates/moon-core/src/market/trade_replay/tests.rs @@ -115,11 +115,11 @@ fn trade_first_paging_protects_position_from_soft_page_and_deadline_stops() { for route in [OkxHistoryTrades, BitgetMixFills, BinanceUsdMAggTrades] { for soft_deadline in [false, true] { // Up to the longest position still walked as one stretch: past - // `long_position_ms()` the plan tiles the two ends only, which the plan's own test + // `LONG_POSITION_MS` the plan tiles the two ends only, which the plan's own test // covers. With the threshold under `TICK_SLICE_MS` no short position spans two // trade tiles any more, so the multi-tile trade prefix this loop once ran (21 min, // three tiles, under the hour-long threshold) is unreachable here by construction. - for duration in [0, 120_000, long_position_ms() - MINUTE_MS] { + for duration in [0, 120_000, LONG_POSITION_MS - MINUTE_MS] { let window = replay_window_ms(100_000_000, 100_000_000 + duration, MARGIN_MS).unwrap(); let plan = tick_plan(window, route, None, ReplayIntent::Chart); @@ -1092,9 +1092,8 @@ fn kline_tick_statuses_keep_the_same_chart_revision_while_ticks_change_it() { #[test] fn focus_spans_split_only_a_long_position() { // A margin well under the position's length, so the two ends of a long one stay apart: - // neighbourhoods that reach each other fold into one stretch, which the end of this test - // pins. - let margin_ms: i64 = long_position_ms() / 5; + // halves that reach each other fold into one stretch, which the end of this test pins. + const MARGIN_MS: i64 = LONG_POSITION_MS / 5; let short = replay_window_ms(100_000_000, 100_000_000 + long_position_ms(), margin_ms).expect("window"); assert_eq!(short.focus_spans(), Coverage::one(short.focus())); diff --git a/crates/moon-core/src/market/trade_replay/worker.rs b/crates/moon-core/src/market/trade_replay/worker.rs index 80fb1e5f..8a346a56 100644 --- a/crates/moon-core/src/market/trade_replay/worker.rs +++ b/crates/moon-core/src/market/trade_replay/worker.rs @@ -239,8 +239,9 @@ pub struct CaptureRequest { pub open_ms: i64, /// The trade's exit, true-UTC milliseconds. pub close_ms: i64, - /// Prints to copy around the trade, per end — [`super::margin_ms`] at close time, whose - /// floor is the tuner's run-up and tail; see [`ReplayWindow::margin_ms`]. + /// Prints to copy around the trade, per end — [`super::model_margin_ms`] at close time, + /// so the tile holds the tuner's run-up and tail whatever the chart's margin is; see + /// [`ReplayWindow::margin_ms`]. pub margin_ms: i64, /// The long-position threshold at close time ([`super::long_position_ms`]): carried so the /// capture's first pass and its settle pass file the same shape whatever the Storage tab diff --git a/crates/moon-core/src/session/lifecycle.rs b/crates/moon-core/src/session/lifecycle.rs index 855db738..9e52ded0 100644 --- a/crates/moon-core/src/session/lifecycle.rs +++ b/crates/moon-core/src/session/lifecycle.rs @@ -510,12 +510,12 @@ impl SessionManager { market, open_ms, close_ms, - // The setting's margin, whose floor is the tuner's run-up and tail - // (`MODEL_PAD_MS`): the capture is what the tuner reads a closed trade's tape - // from, and they must be in the tile or every closed trade re-walks the venue - // for the seconds the ring held for free. - margin_ms: crate::market::trade_replay::margin_ms(), - long_position_ms: crate::market::trade_replay::long_position_ms(), + // The model's margin, not the chart's: the capture is what the tuner reads a + // closed trade's tape from, and its run-up and tail (`model_margin_ms`, at + // least the two pads) must be in the tile or every closed trade re-walks the + // venue for the seconds the ring held for free. A chart margin under the pads + // (5 s is the default) never reaches that far. + margin_ms: crate::market::trade_replay::model_margin_ms(), }, ); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs index 5a8db67a..63d9bac7 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -15,9 +15,9 @@ //! A request is a CLUSTER: the next pending row of a free key — a row the user is looking at //! first ([`prioritize`]), else the oldest — plus every pending row of the //! same market whose window overlaps it, as long as the first entry and the last exit stay -//! within one hour ([`LONG_POSITION_MS`], past which the worker walks only the two ends). One -//! walk of the whole stretch serves them all — a pumped coin closes dozens of trades in an -//! hour, and asked one by one each of them re-walked the same minutes and paid the same page +//! within a long position's length ([`LONG_POSITION_MS`], past which the worker walks only +//! the two ends). One walk of the whole stretch serves them all — a pumped coin closes dozens +//! of trades in minutes, and asked one by one each of them re-walked the same seconds and paid the same page //! budget, and on a venue with small pages (OKX, 100 prints) each of them died on that budget //! in turn. Every row of the cluster is then replayed off the tiles on its own and answered on //! its own. A venue that refuses (the gate's backoff, or its own error mid-walk) puts its rows @@ -629,7 +629,7 @@ fn serve_cluster( let uids: Vec = rows.iter().map(|r| r.deal.report_uid).collect(); let first = &rows[0]; // The hull: the first entry to the last exit, with the seed's margin — what - // `pick_cluster` kept within an hour, so the worker walks it as one stretch. + // `pick_cluster` kept within `LONG_POSITION_MS`, so the worker walks it as one stretch. let first_buy = rows .iter() .map(|r| r.deal.buy_ms) diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs index bffaa150..8af24f0e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs @@ -132,45 +132,58 @@ fn a_row_continues_while_a_short_walk_gains_tape() { } /// A cluster is the seed plus every row of the same market whose margined window overlaps the -/// hull — through a bridge row — never another market or exchange, and never past an hour -/// from the first entry to the last exit. +/// hull — through a bridge row — never another market or exchange, and never past +/// `LONG_POSITION_MS` from the first entry to the last exit. #[test] -fn a_cluster_takes_the_overlapping_rows_of_one_market_within_an_hour() { - const MIN: i64 = 60_000; +fn a_cluster_takes_the_overlapping_rows_of_one_market_within_a_long_position() { + const SEC: i64 = 1_000; let key = |exchange_key, market, buy_ms, close_ms| ClusterKey { exchange_key, market, buy_ms, close_ms, - margin_ms: 5 * MIN, + margin_ms: 30 * SEC, }; - let base = 1_000_000 * MIN; + let base = 1_000_000 * SEC; let rows = [ // 0: another market, same minute — never joins. - key("binance", "BTCUSDT", base, base + MIN), - // 1: the seed's market, 8 min after the seed's close: joins through the margins. - key("binance", "AKEUSDT", base + 10 * MIN, base + 11 * MIN), - // 2: joins only through row 1 (19 min after the seed, 8 after row 1). - key("binance", "AKEUSDT", base + 19 * MIN, base + 20 * MIN), + key("binance", "BTCUSDT", base, base + 10 * SEC), + // 1: the seed's market, 50 s after the seed's close: joins through the margins. + key("binance", "AKEUSDT", base + 70 * SEC, base + 80 * SEC), + // 2: joins only through row 1 (140 s after the seed, 50 after row 1). + key("binance", "AKEUSDT", base + 130 * SEC, base + 140 * SEC), // 3: the seed. - key("binance", "AKEUSDT", base, base + 2 * MIN), - // 4: same market, but 40 min after row 2 — no overlap, stays. - key("binance", "AKEUSDT", base + 60 * MIN, base + 61 * MIN), + key("binance", "AKEUSDT", base, base + 20 * SEC), + // 4: same market, but four minutes after row 2 — no overlap, stays. + key("binance", "AKEUSDT", base + 400 * SEC, base + 410 * SEC), // 5: same market name on another exchange — never joins. - key("gate", "AKEUSDT", base, base + MIN), + key("gate", "AKEUSDT", base, base + 10 * SEC), ]; + assert!( + 140 * SEC + 30 * SEC < LONG_POSITION_MS, + "the cluster stays short" + ); assert_eq!(pick_cluster(&rows, 3), vec![1, 2, 3]); assert_eq!( pick_cluster(&rows, 0), vec![0], "a lone row is its own cluster" ); - // Overlapping rows past the hour from the first entry: the hull stops growing. + // Overlapping rows whose hull would pass a long position's length: the hull stops growing. let long = [ - key("okx", "ONE-USDT-SWAP", base, base + 50 * MIN), - key("okx", "ONE-USDT-SWAP", base + 52 * MIN, base + 70 * MIN), + key( + "okx", + "ONE-USDT-SWAP", + base, + base + LONG_POSITION_MS - 30 * SEC, + ), + key( + "okx", + "ONE-USDT-SWAP", + base + LONG_POSITION_MS - 20 * SEC, + base + LONG_POSITION_MS + 60 * SEC, + ), ]; - assert!(70 * MIN > LONG_POSITION_MS); assert_eq!(pick_cluster(&long, 0), vec![0]); } diff --git a/crates/moon-ui-gpui/src/settings/storage.rs b/crates/moon-ui-gpui/src/settings/storage.rs index e6e18ddf..5bec8386 100644 --- a/crates/moon-ui-gpui/src/settings/storage.rs +++ b/crates/moon-ui-gpui/src/settings/storage.rs @@ -210,7 +210,7 @@ impl SettingsView { } /// Moves the prints kept around a trade, per end, `delta` steps along - /// `TRADE_MARGIN_STEPS_S` (10 s … 120 min, not a fixed amount), and updates live state and + /// `TRADE_MARGIN_STEPS_S` (5 s … 120 min, not a fixed amount), and updates live state and /// storage.toml. fn adjust_trades_margin_step(&mut self, delta: i32, cx: &mut Context) { let v = storage_cfg::step_trade_margin_s(self.storage.cfg.trade_replay.margin_s, delta); diff --git a/locales/storage.yml b/locales/storage.yml index 7091969b..889ee0e7 100644 --- a/locales/storage.yml +++ b/locales/storage.yml @@ -129,9 +129,9 @@ storage.trades_min: en: "%{min} min" es: "%{min} min" storage.trades_margin_hint: - ru: "Сколько трейдов брать с каждого конца сделки: у короткой — до входа и после выхода, у долгой (дольше часа) — столько же вокруг входа и вокруг выхода, середина свечами. Ступени от 10 с до 120 мин." - en: "Prints taken at each end of a trade: before the entry and after the exit of a short one; the same stretch centred on the entry and on the exit of a long one (over an hour), candles between. Steps from 10 s to 120 min." - es: "Operaciones tomadas en cada extremo de una posición: antes de la entrada y después de la salida en una corta; el mismo tramo centrado en la entrada y en la salida en una larga (más de una hora), velas entre ambas. Pasos de 10 s a 120 min." + ru: "Сколько трейдов брать с каждого конца сделки: у короткой — до входа и после выхода, у долгой (дольше 5 мин) — столько же вокруг входа и вокруг выхода, середина свечами. Ступени от 5 с до 120 мин; тюнер берёт не меньше 30 с с каждого края." + en: "Prints taken at each end of a trade: before the entry and after the exit of a short one; the same stretch centred on the entry and on the exit of a long one (over 5 min), candles between. Steps from 5 s to 120 min; the tuner takes at least 30 s at each end." + es: "Operaciones tomadas en cada extremo de una posición: antes de la entrada y después de la salida en una corta; el mismo tramo centrado en la entrada y en la salida en una larga (más de 5 min), velas entre ambas. Pasos de 5 s a 120 min; el afinador toma al menos 30 s en cada extremo." storage.trades_autoload: ru: "Подгружать недостающую ленту при старте" en: "Fetch the missing tape at startup" From 69f7170f64a06e1dd6149c5d1417e45513f555d0 Mon Sep 17 00:00:00 2001 From: guyverino Date: Mon, 21 Sep 2026 10:17:36 +0200 Subject: [PATCH 11/51] feat(trade-replay): file the core's ring into the tiles for a tiles reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../moon-core/src/market/trade_replay/mod.rs | 12 ++++ .../src/market/trade_replay/worker.rs | 32 ++++++++- .../src/market/trade_replay/worker/tests.rs | 69 +++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index fd02abcd..c4b1f5db 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -284,6 +284,18 @@ impl ReplayIntent { matches!(self, Self::Chart) } + /// Whether what the core's ring holds of the window is FILED into the tiles rather than + /// answered from. A model's requester reads the tiles, not the answer: an answer straight + /// from the ring — the candle stage's core-first, the tick stage's own read, the mid-walk + /// upgrade — reached nobody, since none of them files a tile and the close-time capture that + /// would have filed it never ran for a trade that closed while the terminal was down (the + /// autoload's rows). Filed, the ring's stretch is what the walk no longer asks the venue + /// for, and the held query finds it. A chart keeps the ring as an answer: its window shows + /// the series it is sent. + pub(crate) fn files_core(self) -> bool { + matches!(self, Self::Model) + } + /// Whether the remembered-answer ring is read and written for this request. A model's /// request does neither: the ring is keyed by the window alone, so a chart's answer that /// stopped on the page budget before its lead would be served to the model with no run-up, diff --git a/crates/moon-core/src/market/trade_replay/worker.rs b/crates/moon-core/src/market/trade_replay/worker.rs index 8a346a56..50dbb899 100644 --- a/crates/moon-core/src/market/trade_replay/worker.rs +++ b/crates/moon-core/src/market/trade_replay/worker.rs @@ -193,9 +193,7 @@ pub(crate) struct TickStage { /// the stage asks nothing and serves what the tile store and its disk already hold for the /// focus (a capture from the core's archive, filed when the trade closed — or, for a tiles /// reader, filed by this stage itself out of the ring, see [`ReplayIntent::files_core`]), - /// or prints [`TickStatus::NoRoute`] as before when they hold nothing. A route whose - /// retention the whole focus is past is served the same way, printing - /// [`TickStatus::OutOfRetention`] instead. + /// or prints [`TickStatus::NoRoute`] as before when they hold nothing. route: Option, /// The ring key this stage's answer replaces on success. key: OutcomeKey, @@ -1581,8 +1579,35 @@ fn serve_ticks( let key: TileKey = (request.address.exchange_key.clone(), request.market.clone()); let focus = request.window.focus_spans(); let persisted = super::trade_cache::handle(); + // A requester that reads the tiles gets the ring THROUGH them: what the ring holds inside + // the focus is filed as `Core` tiles before the stage decides what is left to fetch, so a + // trade the close-time capture missed costs the venue only what the ring does not hold. + // Run after the disk hydrate on either branch below, and only for a focus the store does + // not already hold whole: the ring copy scans the donor's whole retained ring, and a + // retry of a row the disk answered would pay it for nothing. + let file_ring = || { + if !request.intent.files_core() { + return; + } + let held_whole = { + let store = lock_tiles(tiles); + held_coverage(&store, &key, &focus, Coverage::none()).covers(&focus) + }; + if held_whole { + return; + } + file_core_into_tiles(&request.address, &request.market, &focus, tiles, |span| { + request.address.history.capture_core_span( + &request.address, + &request.market, + span.0, + span.1, + ) + }); + }; let Some(route) = stage.route else { hydrate(tiles, persisted.as_ref(), &key, &focus); + file_ring(); // No venue to ask: the focus is served from what the tiles hold inside it — a capture // from the core's archive — or the window prints that there is no route, as before. let (covered, runs) = { @@ -1640,6 +1665,7 @@ fn serve_ticks( } // After the retention refusal, which is free: a window too old for the route pays no read. hydrate(tiles, persisted.as_ref(), &key, &focus); + file_ring(); let residual = residual_plan(&plan, &lock_tiles(tiles), &key); // The one line that tells a neighbouring window apart from a reopen: the focus is the // window's own, the spans are what the store made of it. In milliseconds, not slices — a diff --git a/crates/moon-core/src/market/trade_replay/worker/tests.rs b/crates/moon-core/src/market/trade_replay/worker/tests.rs index 62ed9353..628b3153 100644 --- a/crates/moon-core/src/market/trade_replay/worker/tests.rs +++ b/crates/moon-core/src/market/trade_replay/worker/tests.rs @@ -1086,3 +1086,72 @@ fn probe_one_slice_against_the_venue() { &gaps[..gaps.len().min(5)] ); } + +/// `worker.rs:file_core_into_tiles` answering a tiles reader from the ring instead of filing it +/// leaves the model's held query empty and the walk's residual whole: the ring's stretch is +/// paid to the venue again, or — when the ring answered in place of the walk — never fetched +/// at all, and the row stays missing on every fetch (19 of 925 rows on 2026-09-21). +#[test] +fn a_tiles_reader_gets_the_ring_as_core_tiles_the_walk_no_longer_asks_for() { + let history = + crate::market::source::MarketDataSource::new(crate::market::MarketStore::shared(0.0)); + let venue = crate::venue::venue(3).expect("Binance spot"); + let address = ReplayAddress { + history, + venue, + exchange_key: "3:00000000".into(), + cache: None, + }; + let key: TileKey = (address.exchange_key.clone(), "BTCUSDT".into()); + let (open_ms, close_ms) = (100_000_000, 100_120_000); + let window = super::super::replay_window_ms(open_ms, close_ms, 60_000).expect("window"); + let focus = window.focus_spans(); + let (focus_from, focus_to) = focus.hull().expect("one focus"); + let tiles = Mutex::new(TickTileStore::default()); + let asked = std::cell::RefCell::new(Vec::new()); + // The ring holds the position and a little after it, not the lead before the entry. + let ring = (open_ms - 5_000, close_ms + 30_000); + let filed = file_core_into_tiles(&address, "BTCUSDT", &focus, &tiles, |span| { + asked.borrow_mut().push(span); + let from = span.0.max(ring.0); + let to = span.1.min(ring.1); + (from <= to).then(|| crate::market::source::CoreReplayTicks { + ticks: vec![tick(from, 1.0), tick(to, 2.0)], + covered: (from, to), + }) + }); + assert_eq!( + asked.borrow().as_slice(), + focus.spans(), + "one read per focus span" + ); + assert_eq!(filed, vec![(ring.0.max(focus_from), ring.1.min(focus_to))]); + + // What the walk still owes the venue is exactly what the ring did not hold. + let route = TradeRoute::BinanceSpotAggTrades; + let plan = tick_plan(window, route, None, ReplayIntent::Model); + let residual = residual_plan(&plan, &lock_tiles(&tiles), &key); + let residual_ms: i64 = residual.slices.iter().map(|(a, b)| b - a + 1).sum(); + let plan_ms: i64 = plan.slices.iter().map(|(a, b)| b - a + 1).sum(); + assert!( + residual_ms < plan_ms, + "the ring's stretch left the residual" + ); + assert!( + residual + .slices + .iter() + .all(|&(a, b)| b < ring.0 || a > ring.1), + "nothing inside the filed stretch is asked again: {:?}", + residual.slices + ); + // And the tiles say who answered. + let store = lock_tiles(&tiles); + let held = held_coverage(&store, &key, &focus, Coverage::none()); + assert!(held.contains((ring.0.max(focus_from), ring.1.min(focus_to)))); + drop(store); + + // A chart keeps the ring as an answer; a model never takes it in place of the walk. + assert!(!ReplayIntent::Chart.files_core()); + assert!(ReplayIntent::Model.files_core()); +} From b1a361ebd63891dabfc66d1ab45d6ceb64a4d0ea Mon Sep 17 00:00:00 2001 From: guyverino Date: Mon, 21 Sep 2026 11:49:00 +0200 Subject: [PATCH 12/51] feat(storage): trade-tape cleanup on the Storage tab and at startup, long-position threshold as a setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- crates/moon-core/src/config/storage.rs | 35 ++++++- crates/moon-core/src/config/storage/tests.rs | 37 +++++++ crates/moon-core/src/db/tape_owners.rs | 9 +- crates/moon-core/src/db/tape_owners/tests.rs | 1 - .../moon-core/src/market/trade_replay/mod.rs | 49 ++++----- .../src/market/trade_replay/settings.rs | 46 ++++++++- .../src/market/trade_replay/tests.rs | 12 +-- .../src/market/trade_replay/worker.rs | 24 +++++ crates/moon-core/src/session/lifecycle.rs | 1 + .../analytics/tuner/ticks/fetch/autoload.rs | 10 +- .../src/analytics/tuner/ticks/fetch/job.rs | 41 +++++--- .../analytics/tuner/ticks/fetch/job/tests.rs | 16 +-- .../src/analytics/tuner/ticks/load.rs | 40 ++++++-- crates/moon-ui-gpui/src/settings/storage.rs | 56 +++++++++++ .../src/settings/storage/trades_cleanup.rs | 81 +++++---------- .../storage/trades_cleanup/startup.rs | 20 +++- .../settings/storage/trades_cleanup/tests.rs | 99 +++++-------------- crates/moon-ui-gpui/src/startup/boot.rs | 7 +- locales/storage.yml | 60 +++++++++-- 19 files changed, 425 insertions(+), 219 deletions(-) diff --git a/crates/moon-core/src/config/storage.rs b/crates/moon-core/src/config/storage.rs index 821add6a..04615bdf 100644 --- a/crates/moon-core/src/config/storage.rs +++ b/crates/moon-core/src/config/storage.rs @@ -62,8 +62,8 @@ pub struct TradeReplayStoreCfg { /// longest ago go first. `0` keeps everything, with no age limit. pub max_mb: u32, /// Seconds of prints kept around a trade, per end: a short position gets this much before - /// its entry and after its exit; a long one (over an hour) gets this much centred on each - /// end, half before and half after, with bars between. It sizes what a trade window fetches, + /// its entry and after its exit; a long one ([`Self::long_position_min`] or longer) gets + /// this much centred on each end, half before and half after, with bars between. It sizes what a trade window fetches, /// what a close copies out of the core's ring, and what the file keeps. One of /// [`TRADE_MARGIN_STEPS_S`]: a hand-edited value is snapped to the nearest step on load. pub margin_s: u32, @@ -72,6 +72,16 @@ pub struct TradeReplayStoreCfg { /// missed because the terminal was not running. Off by default: it spends the venues' public /// request budget without being asked. A file written before the field reads as off. pub autoload_missing: bool, + /// Minutes a position must be held to count as LONG — walked as its two ends with bars + /// between, both by a trade window and by the tuner's fetch, whose clusters stay within it. + /// Bounded to [`LONG_POSITION_MIN_RANGE`] on load; a file written before the field reads + /// as the default, which is what the threshold was while it was a constant. + pub long_position_min: u32, + /// Whether the terminal runs the Storage tab's cleanup on its own once the cores are up — + /// before the tape autoload, so what the cleanup removes is not what the autoload just + /// fetched. Off by default: it rewrites the file unasked. A file written before the field + /// reads as off. + pub cleanup_at_startup: bool, } /// Default minutes of [`TradeReplayStoreCfg::long_position_min`]: the five minutes the @@ -111,6 +121,8 @@ impl Default for TradeReplayStoreCfg { max_mb: DEFAULT_TRADES_MAX_MB, margin_s: DEFAULT_TRADE_MARGIN_S, autoload_missing: false, + long_position_min: DEFAULT_LONG_POSITION_MIN, + cleanup_at_startup: false, } } } @@ -126,6 +138,8 @@ struct TradeReplayStoreRaw { margin_s: Option, margin_min: Option, autoload_missing: bool, + long_position_min: u32, + cleanup_at_startup: bool, } impl Default for TradeReplayStoreRaw { @@ -137,6 +151,8 @@ impl Default for TradeReplayStoreRaw { margin_s: None, margin_min: None, autoload_missing: d.autoload_missing, + long_position_min: d.long_position_min, + cleanup_at_startup: d.cleanup_at_startup, } } } @@ -152,10 +168,20 @@ impl From for TradeReplayStoreCfg { max_mb: raw.max_mb, margin_s, autoload_missing: raw.autoload_missing, + long_position_min: raw.long_position_min, + cleanup_at_startup: raw.cleanup_at_startup, } } } +/// [`LONG_POSITION_MIN_RANGE`] applied to a value from the file or the tab. +pub fn clamp_long_position_min(minutes: u32) -> u32 { + minutes.clamp( + *LONG_POSITION_MIN_RANGE.start(), + *LONG_POSITION_MIN_RANGE.end(), + ) +} + /// The step of [`TRADE_MARGIN_STEPS_S`] nearest to `secs` — the lower one when `secs` sits /// exactly between two (a migrated `margin_min = 45` lands on 30 minutes, not 60). Anything /// past the last step is the last step, anything under the first is the first. @@ -234,10 +260,13 @@ pub fn load() -> StorageCfg { sanitize(toml_io::load_or_default(&path, "storage.toml", |_| {})) } -/// Bound what a hand-edited file may carry: the margin is snapped onto [`TRADE_MARGIN_STEPS_S`], +/// Bound what a hand-edited file may carry: the long-position threshold is clamped to +/// [`LONG_POSITION_MIN_RANGE`], and the margin is snapped onto [`TRADE_MARGIN_STEPS_S`], /// which also caps it at [`MAX_TRADE_MARGIN_S`]. fn sanitize(mut cfg: StorageCfg) -> StorageCfg { cfg.trade_replay.margin_s = snap_trade_margin_s(cfg.trade_replay.margin_s); + cfg.trade_replay.long_position_min = + clamp_long_position_min(cfg.trade_replay.long_position_min); cfg } diff --git a/crates/moon-core/src/config/storage/tests.rs b/crates/moon-core/src/config/storage/tests.rs index 0127d733..276c9472 100644 --- a/crates/moon-core/src/config/storage/tests.rs +++ b/crates/moon-core/src/config/storage/tests.rs @@ -19,6 +19,43 @@ max_mb = 512 // The autoload switch came after the margin and reads as OFF from a file without it: it // spends the venues' budget, and may not switch itself on. assert!(!cfg.trade_replay.autoload_missing); + // The long-position threshold came later still and reads as the five minutes it was as a + // constant; the startup cleanup, like the autoload, may not switch itself on. + assert_eq!( + cfg.trade_replay.long_position_min, + DEFAULT_LONG_POSITION_MIN + ); + assert!(!cfg.trade_replay.cleanup_at_startup); +} + +/// The long-position threshold round-trips through the file and is bounded where `load` +/// bounds it: a hand-edited zero — every trade "long" — becomes the floor, an hour past the +/// ceiling becomes the ceiling. +#[test] +fn long_position_min_round_trips_and_is_clamped_on_load() { + let mut cfg = StorageCfg::default(); + cfg.trade_replay.long_position_min = 15; + cfg.trade_replay.cleanup_at_startup = true; + let text = toml::to_string(&cfg).expect("serialises"); + assert!(text.contains("long_position_min = 15"), "{text}"); + assert!(text.contains("cleanup_at_startup = true"), "{text}"); + let back: StorageCfg = toml::from_str(&text).expect("parses"); + assert_eq!(back.trade_replay.long_position_min, 15); + assert!(back.trade_replay.cleanup_at_startup); + let zero: StorageCfg = toml::from_str( + "[trade_replay] +long_position_min = 0 +", + ) + .expect("parses"); + assert_eq!(sanitize(zero).trade_replay.long_position_min, 1); + let huge: StorageCfg = toml::from_str( + "[trade_replay] +long_position_min = 180 +", + ) + .expect("parses"); + assert_eq!(sanitize(huge).trade_replay.long_position_min, 120); } /// A file written while the margin was `margin_min` (minutes) — what every terminal installed diff --git a/crates/moon-core/src/db/tape_owners.rs b/crates/moon-core/src/db/tape_owners.rs index bf01a724..f3f9fd6a 100644 --- a/crates/moon-core/src/db/tape_owners.rs +++ b/crates/moon-core/src/db/tape_owners.rs @@ -22,10 +22,6 @@ pub struct TapeOwner { pub buy: ReportStamp, /// Exit stamp, same caveat. pub close: ReportStamp, - /// The entry order's creation (`buysetdatems`), core-local milliseconds like the entry's — - /// the tuner fetches a trade's tape from there (`tuner::ticks::model_window_at`), so the - /// cleanup must claim from there too. `None` on rows and replicas that do not carry it. - pub buy_set_ms: Option, pub strategy_id: i64, /// `sellreason` as the core wrote it. pub sell_reason: String, @@ -91,13 +87,11 @@ fn read_rows( } }; let sql = format!( - "SELECT core_uid, coin, buydate, closedate, {buy_ms}, {close_ms}, strategyid, sellreason, - {buy_set_ms} + "SELECT core_uid, coin, buydate, closedate, {buy_ms}, {close_ms}, strategyid, sellreason FROM {table} WHERE closedate > 0 AND closedate >= ?1 AND buydate <= ?2", buy_ms = column("buydatems"), close_ms = column("closedatems"), - buy_set_ms = column("buysetdatems"), table = rep::TABLE, ); let fail = |e: rusqlite::Error| super::read_fail::read_fail(CTX, e); @@ -115,7 +109,6 @@ fn read_rows( close: ReportStamp::resolve(close_s, close_ms), strategy_id: r.get::<_, Option>(6)?.unwrap_or(0), sell_reason: r.get::<_, Option>(7)?.unwrap_or_default(), - buy_set_ms: r.get::<_, Option>(8)?.filter(|&set| set > 0), kind: String::new(), }) }) diff --git a/crates/moon-core/src/db/tape_owners/tests.rs b/crates/moon-core/src/db/tape_owners/tests.rs index 76f2b0a1..5e151512 100644 --- a/crates/moon-core/src/db/tape_owners/tests.rs +++ b/crates/moon-core/src/db/tape_owners/tests.rs @@ -82,7 +82,6 @@ fn is_tunable_follows_the_axis_filter() { close: ReportStamp::Seconds(2), strategy_id, sell_reason: sell_reason.into(), - buy_set_ms: None, kind: kind.into(), }; assert!(owner(42, "MoonShot", "Sell Price").is_tunable()); diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index c4b1f5db..b6c22603 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -42,7 +42,10 @@ use crate::market::candles::ChartCandle; use crate::market::{CandleReadParams, ChartHistoryBuffers, ChartHistoryRead}; use crate::venue::{Brand, Venue}; pub use coverage::Coverage; -pub use settings::{margin_ms, model_margin_ms, set_margin_s, set_tape_autoload, tape_autoload}; +pub use settings::{ + cleanup_at_startup, long_position_ms, margin_ms, model_margin_ms, set_cleanup_at_startup, + set_long_position_min, set_margin_s, set_tape_autoload, tape_autoload, +}; pub use worker::{TickAnswer, TickQuery, query_held}; /// Milliseconds in one minute, the only timeframe a replay is fetched at. @@ -78,24 +81,24 @@ const MAX_SPAN_MS: i64 = 7 * 24 * 60 * MINUTE_MS; /// exit — so a window clipped exactly to the position would answer the wrong question. const CONTEXT_FRACTION: f64 = 0.5; -/// A position held longer than this asks for ticks only around its entry and its exit -/// ([`ReplayWindow::focus_spans`]), each end getting the window's margin centred on it; the -/// middle stays bars. -/// -/// A meaning bound, not a resource one: the page budget already caps what a walk can fetch, but -/// on a multi-hour position it burned out ~40 minutes after the entry and the exit came back as -/// bars — while at the zoom such a position is viewed at, the chart draws bars for the middle -/// anyway. Five minutes is the developer's call (2026-09-21; an hour the day before): past it -/// the ticks between the ends are a ribbon nobody reads, and what matters is how the entry and -/// the exit printed. The tuner's model runs a long position on that two-end tape as it is — -/// also the developer's call, the same day: an exit that really happened in the unwalked -/// middle is a miss in the replay, and the deal table's "held" column shows how far the tape -/// reaches on each side. -/// -/// Public because the tuner's fetch clusters several trades of one market into one request -/// whose open is the first entry and whose close is the last exit: a cluster longer than this -/// would be walked as two ends, and the trades in between would go without their tape. -pub const LONG_POSITION_MS: i64 = 5 * MINUTE_MS; +// A position held longer than `[trade_replay] long_position_min` ([`long_position_ms`]) asks +// for ticks only around its entry and its exit ([`ReplayWindow::focus_spans`]), each end +// getting the window's margin centred on it; the middle stays bars. +// +// A meaning bound, not a resource one: the page budget already caps what a walk can fetch, but +// on a multi-hour position it burned out ~40 minutes after the entry and the exit came back as +// bars — while at the zoom such a position is viewed at, the chart draws bars for the middle +// anyway. Five minutes was the developer's call as a constant (2026-09-21; an hour the day +// before) and is the default now that the Storage tab moves it: past it the ticks between the +// ends are a ribbon nobody reads, and what matters is how the entry and the exit printed. The +// tuner's model runs a long position on that two-end tape as it is — also the developer's +// call, the same day: an exit that really happened in the unwalked middle is a miss in the +// replay, and the deal table's "held" column shows how far the tape reaches on each side. +// +// The tuner's fetch clusters several trades of one market into one request whose open is the +// first entry and whose close is the last exit, and keeps the cluster within the same +// threshold: a longer one would be walked as two ends, and the trades in between would go +// without their tape. /// How far before the entry and past the exit a MODEL's request treats the tape as part of the /// trade itself (walked under the trade budget, never cut short by the normal page ceiling): @@ -388,11 +391,9 @@ impl ReplayWindow { (left, right) } /// The stretches actually requested as ticks: the whole [`Self::focus`] on a position held up - /// to [`Self::long_position_ms`]; on a longer one, [`Self::margin_ms`] on both sides of the - /// entry and of the exit — two spans with the middle left to bars. Both sides, not half the - /// margin each: the stretch before the entry and past the exit is what the tuner's run-up - /// and exit horizon are, and a long position must get the same margin there as a short one - /// (the developer's call, 2026-09-23; the margin was centred on each end before). + /// to [`Self::long_position_ms`]; on a longer one, [`Self::margin_ms`] centred on the entry + /// and on the exit — half before each end, half after — two spans with the middle left to + /// bars. /// /// Returns: /// One or two spans, each clamped into `[Self::from_ms, Self::to_ms]`. The two of a long diff --git a/crates/moon-core/src/market/trade_replay/settings.rs b/crates/moon-core/src/market/trade_replay/settings.rs index b3a9b277..f2da5fb2 100644 --- a/crates/moon-core/src/market/trade_replay/settings.rs +++ b/crates/moon-core/src/market/trade_replay/settings.rs @@ -13,6 +13,11 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; static MARGIN_S: AtomicU32 = AtomicU32::new(crate::config::storage::DEFAULT_TRADE_MARGIN_S); /// Live value of `[trade_replay] autoload_missing`. static TAPE_AUTOLOAD: AtomicBool = AtomicBool::new(false); +/// Live value of `[trade_replay] long_position_min`. +static LONG_POSITION_MIN: AtomicU32 = + AtomicU32::new(crate::config::storage::DEFAULT_LONG_POSITION_MIN); +/// Live value of `[trade_replay] cleanup_at_startup`. +static CLEANUP_AT_STARTUP: AtomicBool = AtomicBool::new(false); static INIT: OnceLock<()> = OnceLock::new(); /// Load the file into the cells once; every setter calls it first, or the file's value would @@ -22,11 +27,15 @@ fn init() { let cfg = crate::config::storage::load(); MARGIN_S.store(cfg.trade_replay.margin_s, Ordering::Relaxed); TAPE_AUTOLOAD.store(cfg.trade_replay.autoload_missing, Ordering::Relaxed); + LONG_POSITION_MIN.store(cfg.trade_replay.long_position_min, Ordering::Relaxed); + CLEANUP_AT_STARTUP.store(cfg.trade_replay.cleanup_at_startup, Ordering::Relaxed); // Once per launch, so a file migrated from `margin_min` shows what it was read as. log::info!( - "[x] trade-replay settings: margin {} s, tape autoload {}", + "[x] trade-replay settings: margin {} s, long position from {} min, tape autoload {}, cleanup at startup {}", cfg.trade_replay.margin_s, - cfg.trade_replay.autoload_missing + cfg.trade_replay.long_position_min, + cfg.trade_replay.autoload_missing, + cfg.trade_replay.cleanup_at_startup ); }); } @@ -73,3 +82,36 @@ pub fn set_tape_autoload(on: bool) { init(); TAPE_AUTOLOAD.store(on, Ordering::Relaxed); } + +/// How long a position must be held to be walked as its two ends — `[trade_replay] +/// long_position_min`, in milliseconds. Captured where a window is built +/// ([`super::replay_window_ms`] → [`super::ReplayWindow::long_position_ms`]) and read from the +/// window from then on, like the margin: a request is clustered, walked, judged and drawn at +/// different moments, and every one of them must split it the same way. +pub fn long_position_ms() -> i64 { + init(); + i64::from(LONG_POSITION_MIN.load(Ordering::Relaxed)) * 60_000 +} + +/// Move the live threshold; the Storage tab writes `storage.toml` beside this. Bounded like +/// the file is on load. +pub fn set_long_position_min(minutes: u32) { + init(); + LONG_POSITION_MIN.store( + crate::config::storage::clamp_long_position_min(minutes), + Ordering::Relaxed, + ); +} + +/// Whether the terminal runs the trade-tape cleanup on its own once the cores are up — +/// `[trade_replay] cleanup_at_startup`. +pub fn cleanup_at_startup() -> bool { + init(); + CLEANUP_AT_STARTUP.load(Ordering::Relaxed) +} + +/// Move the live startup-cleanup switch; the Storage tab writes the file beside this. +pub fn set_cleanup_at_startup(on: bool) { + init(); + CLEANUP_AT_STARTUP.store(on, Ordering::Relaxed); +} diff --git a/crates/moon-core/src/market/trade_replay/tests.rs b/crates/moon-core/src/market/trade_replay/tests.rs index fe425412..9ba47d64 100644 --- a/crates/moon-core/src/market/trade_replay/tests.rs +++ b/crates/moon-core/src/market/trade_replay/tests.rs @@ -115,11 +115,11 @@ fn trade_first_paging_protects_position_from_soft_page_and_deadline_stops() { for route in [OkxHistoryTrades, BitgetMixFills, BinanceUsdMAggTrades] { for soft_deadline in [false, true] { // Up to the longest position still walked as one stretch: past - // `LONG_POSITION_MS` the plan tiles the two ends only, which the plan's own test + // `long_position_ms()` the plan tiles the two ends only, which the plan's own test // covers. With the threshold under `TICK_SLICE_MS` no short position spans two // trade tiles any more, so the multi-tile trade prefix this loop once ran (21 min, // three tiles, under the hour-long threshold) is unreachable here by construction. - for duration in [0, 120_000, LONG_POSITION_MS - MINUTE_MS] { + for duration in [0, 120_000, long_position_ms() - MINUTE_MS] { let window = replay_window_ms(100_000_000, 100_000_000 + duration, MARGIN_MS).unwrap(); let plan = tick_plan(window, route, None, ReplayIntent::Chart); @@ -1086,14 +1086,13 @@ fn kline_tick_statuses_keep_the_same_chart_revision_while_ticks_change_it() { } /// A position held up to `long_position_ms()` keeps one focus; past it the focus is two -/// neighbourhoods — the whole margin on both sides of each end, so the run-up before the entry -/// and the tail past the exit are the margin, as on a short position — clamped into the window -/// like the whole one. +/// neighbourhoods — the margin centred on each end, half before and half after — clamped into +/// the window like the whole one. #[test] fn focus_spans_split_only_a_long_position() { // A margin well under the position's length, so the two ends of a long one stay apart: // halves that reach each other fold into one stretch, which the end of this test pins. - const MARGIN_MS: i64 = LONG_POSITION_MS / 5; + let margin_ms: i64 = long_position_ms() / 5; let short = replay_window_ms(100_000_000, 100_000_000 + long_position_ms(), margin_ms).expect("window"); assert_eq!(short.focus_spans(), Coverage::one(short.focus())); @@ -1102,6 +1101,7 @@ fn focus_spans_split_only_a_long_position() { let close_ms = open_ms + long_position_ms() + 1; let long = replay_window_ms(open_ms, close_ms, margin_ms).expect("window"); let spans = long.focus_spans(); + let half = margin_ms / 2; assert_eq!( spans.spans(), &[ diff --git a/crates/moon-core/src/market/trade_replay/worker.rs b/crates/moon-core/src/market/trade_replay/worker.rs index 50dbb899..45213a2e 100644 --- a/crates/moon-core/src/market/trade_replay/worker.rs +++ b/crates/moon-core/src/market/trade_replay/worker.rs @@ -287,6 +287,8 @@ enum Inbound { /// A lane armed a native follow-up for a request it answered; the coordinator polls it. /// Boxed for the same reason as [`Job::Native`]. NativeWait(Box<(TradeReplayRequest, NativeWait)>), + /// Drop every held tile and remembered answer — see [`forget_tiles`]. + ForgetTiles, } /// One unit of a lane's own queue: the venue calls of one request. @@ -590,6 +592,17 @@ pub fn query_held(query: TickQuery) { send(Inbound::Held(query)); } +/// Drop every tile the worker holds in memory, and every remembered answer with them. +/// +/// For the Storage tab, after it cut `trades.sqlite` down: the disk is the tile store's memory +/// and the two must not disagree about what is held — a held-data query reads the tiles first, +/// and would go on answering "held" for prints the file no longer has until the process +/// restarted. Emptied, the tiles fill again from the trimmed disk on the next ask. Returns at +/// once; a lane mid-walk files what it fetched into the emptied store as it always did. +pub fn forget_tiles() { + send(Inbound::ForgetTiles); +} + fn send(inbound: Inbound) { let worker = WORKER.get_or_init(|| { let (tx, rx) = mpsc::channel::(); @@ -652,6 +665,17 @@ fn enqueue( queue.push_back(Job::Capture(request, spans, false)); } Inbound::Held(query) => queue.push_back(Job::Held(query)), + Inbound::ForgetTiles => { + // Straight here, not through the queue: nothing queued behind it may keep + // answering from tiles the disk has already lost. + *lock_tiles(&shared.tiles) = TickTileStore::default(); + shared + .cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + log::info!("[x] trade-replay tiles and remembered answers dropped after a trim"); + } } } diff --git a/crates/moon-core/src/session/lifecycle.rs b/crates/moon-core/src/session/lifecycle.rs index 9e52ded0..85031726 100644 --- a/crates/moon-core/src/session/lifecycle.rs +++ b/crates/moon-core/src/session/lifecycle.rs @@ -516,6 +516,7 @@ impl SessionManager { // venue for the seconds the ring held for free. A chart margin under the pads // (5 s is the default) never reaches that far. margin_ms: crate::market::trade_replay::model_margin_ms(), + long_position_ms: crate::market::trade_replay::long_position_ms(), }, ); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs index 30d8e2bf..8994b36f 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs @@ -20,6 +20,11 @@ //! again every [`RETRY`] for up to [`MAX_ATTEMPTS`]: the cores come up one by one after the //! terminal, and the catalog a little after each core. //! +//! The first pass yields to the startup cleanup of the trade tape +//! (`settings::trades_cleanup_startup`, behind `[trade_replay] cleanup_at_startup`): the +//! cleanup cuts the file to what the tuner's rows claim, this then fetches what they still +//! lack — the other order would fetch first and cut second. +//! //! The read and the resolution run on the background executor; the tick only decides whether //! one is due. "Stop" on the axis' button cancels what remains ([`cancel`]); flipping the //! switch off and on again re-arms it. @@ -124,11 +129,14 @@ pub(crate) fn tick(backend: &Backend, cx: &App) { attempts: 0, }; } + // The first pass also waits for the startup cleanup + // (`settings::trades_cleanup_startup`): what it removes must not be what this pass + // just fetched. A retry pass has the same guard for free — the cleanup is done by then. Phase::Waiting { due, left, attempts, - } if due <= now => { + } if due <= now && crate::settings::trades_cleanup_startup::clear_for_autoload() => { st.phase = Phase::Running; st.generation += 1; let generation = st.generation; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs index 63d9bac7..435c3044 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -15,8 +15,8 @@ //! A request is a CLUSTER: the next pending row of a free key — a row the user is looking at //! first ([`prioritize`]), else the oldest — plus every pending row of the //! same market whose window overlaps it, as long as the first entry and the last exit stay -//! within a long position's length ([`LONG_POSITION_MS`], past which the worker walks only -//! the two ends). One walk of the whole stretch serves them all — a pumped coin closes dozens +//! within a long position's length (`[trade_replay] long_position_min`, past which the worker +//! walks only the two ends). One walk of the whole stretch serves them all — a pumped coin closes dozens //! of trades in minutes, and asked one by one each of them re-walked the same seconds and paid the same page //! budget, and on a venue with small pages (OKX, 100 prints) each of them died on that budget //! in turn. Every row of the cluster is then replayed off the tiles on its own and answered on @@ -41,8 +41,8 @@ use moon_core::market::ReplayAddress; use moon_core::market::trade_replay::venue_caps::TickValue; use moon_core::market::trade_replay::worker::{self, TradeReplayRequest}; use moon_core::market::trade_replay::{ - Coverage, LONG_POSITION_MS, ReplayIntent, ReplayWindow, TickStatus, TradeReplayEmpty, - TradeReplayFailure, TradeReplayOutcome, replay_window_ms, + Coverage, ReplayIntent, ReplayWindow, TickStatus, TradeReplayEmpty, TradeReplayFailure, + TradeReplayOutcome, replay_window_ms, }; /// The wait after a venue's own refusal mid-walk, when the gate names no number: the gate's own @@ -436,16 +436,22 @@ pub(super) struct ClusterKey<'a> { /// The rows that go out with the seed in one request: every pending row of the seed's market /// whose margined window overlaps the cluster's hull, taken while the hull's first entry and -/// last exit stay within [`LONG_POSITION_MS`]. Grows until nothing more joins — a row that -/// joins can bridge to the next one. +/// last exit stay within `long_position_ms` — the seed's own threshold, captured when its window +/// was built (`ReplayWindow::long_position_ms`), past which the worker walks a stretch as its +/// two ends. Grows until nothing more joins — a row that joins can bridge to the next one. /// /// Args: /// rows: The pending rows' keys, in queue order. /// seed: The index of the row the dispatcher picked. +/// long_position_ms: The longest hull, first entry to last exit, walked as one stretch. /// /// Returns: /// The indices of the cluster, the seed included, ascending. -pub(super) fn pick_cluster(rows: &[ClusterKey<'_>], seed: usize) -> Vec { +pub(super) fn pick_cluster( + rows: &[ClusterKey<'_>], + seed: usize, + long_position_ms: i64, +) -> Vec { let anchor = rows[seed]; let mut taken = vec![seed]; let (mut first_buy, mut last_close) = (anchor.buy_ms, anchor.close_ms); @@ -464,7 +470,7 @@ pub(super) fn pick_cluster(rows: &[ClusterKey<'_>], seed: usize) -> Vec { >= first_buy.saturating_sub(anchor.margin_ms); let hull_from = first_buy.min(row.buy_ms); let hull_to = last_close.max(row.close_ms); - if overlaps && hull_to.saturating_sub(hull_from) <= LONG_POSITION_MS { + if overlaps && hull_to.saturating_sub(hull_from) <= long_position_ms { taken.push(index); first_buy = hull_from; last_close = hull_to; @@ -545,7 +551,9 @@ fn run(job: &'static Job) { margin_ms: row.window.margin_ms, }) .collect(); - let indices = pick_cluster(&keys, index); + // The seed's own threshold, captured when its window was built — the same one + // the walk and the post-walk check judge the row by. + let indices = pick_cluster(&keys, index, st.pending[index].window.long_position_ms); // Removed from the back, so each index still names the row it was picked for. let mut rows: Vec = indices .iter() @@ -629,7 +637,8 @@ fn serve_cluster( let uids: Vec = rows.iter().map(|r| r.deal.report_uid).collect(); let first = &rows[0]; // The hull: the first entry to the last exit, with the seed's margin — what - // `pick_cluster` kept within `LONG_POSITION_MS`, so the worker walks it as one stretch. + // `pick_cluster` kept within the long-position threshold, so the worker walks it as one + // stretch. let first_buy = rows .iter() .map(|r| r.deal.buy_ms) @@ -640,8 +649,14 @@ fn serve_cluster( .map(|r| r.deal.close_ms) .max() .unwrap_or(first.deal.close_ms); - let window = - replay_window_ms(first_buy, last_close, first.window.margin_ms).unwrap_or(first.window); + let window = replay_window_ms(first_buy, last_close, first.window.margin_ms) + .map(|hull| ReplayWindow { + // The seed's threshold, not a fresh read: the hull was clustered by it, and the + // walk and the post-walk check must split it the same way. + long_position_ms: first.window.long_position_ms, + ..hull + }) + .unwrap_or(first.window); let (reply, rx) = mpsc::channel(); let started = Instant::now(); worker::request(TradeReplayRequest { @@ -713,7 +728,7 @@ fn serve_cluster( entry_start: None, held: None, }; - replay_row(&mut answer, defaults, lines); + replay_row(&mut answer, defaults, lines, row.window.long_position_ms); let mut wait = retry_wait(status, answer.tape).map(|s| Duration::from_secs(u64::from(s))); // Back to the end of the venue's turn, for the next walk to continue from where the // tiles end — see [`continues`]; the ceiling, no gain, or any other word is final. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs index 8af24f0e..f6ffb9ee 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs @@ -7,7 +7,7 @@ use super::{ ClusterKey, MAX_CONTINUATIONS, MAX_IN_FLIGHT, continues, pick_cluster, pick_dispatchable, retry_wait, split_by_key, }; -use moon_core::market::trade_replay::{LONG_POSITION_MS, TickStatus}; +use moon_core::market::trade_replay::TickStatus; /// Only a gate refusal on a still-uncovered row is asked again, with the gate's own number; /// a row the tiles covered anyway, and every other status, is final here (a venue's own @@ -133,10 +133,12 @@ fn a_row_continues_while_a_short_walk_gains_tape() { /// A cluster is the seed plus every row of the same market whose margined window overlaps the /// hull — through a bridge row — never another market or exchange, and never past -/// `LONG_POSITION_MS` from the first entry to the last exit. +/// the long-position threshold from the first entry to the last exit. #[test] fn a_cluster_takes_the_overlapping_rows_of_one_market_within_a_long_position() { const SEC: i64 = 1_000; + /// The threshold as this test hands it in: the default five minutes. + const LONG_POSITION_MS: i64 = 5 * 60 * SEC; let key = |exchange_key, market, buy_ms, close_ms| ClusterKey { exchange_key, market, @@ -159,13 +161,13 @@ fn a_cluster_takes_the_overlapping_rows_of_one_market_within_a_long_position() { // 5: same market name on another exchange — never joins. key("gate", "AKEUSDT", base, base + 10 * SEC), ]; - assert!( + const _: () = assert!( 140 * SEC + 30 * SEC < LONG_POSITION_MS, "the cluster stays short" ); - assert_eq!(pick_cluster(&rows, 3), vec![1, 2, 3]); + assert_eq!(pick_cluster(&rows, 3, LONG_POSITION_MS), vec![1, 2, 3]); assert_eq!( - pick_cluster(&rows, 0), + pick_cluster(&rows, 0, LONG_POSITION_MS), vec![0], "a lone row is its own cluster" ); @@ -184,7 +186,9 @@ fn a_cluster_takes_the_overlapping_rows_of_one_market_within_a_long_position() { base + LONG_POSITION_MS + 60 * SEC, ), ]; - assert_eq!(pick_cluster(&long, 0), vec![0]); + assert_eq!(pick_cluster(&long, 0, LONG_POSITION_MS), vec![0]); + // A wider threshold takes the same two rows as one cluster. + assert_eq!(pick_cluster(&long, 0, 2 * LONG_POSITION_MS), vec![0, 1]); } /// A deferral takes every queued row of the refused venue, in queue order, and leaves the diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 04e7fb53..47d8d791 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -282,7 +282,10 @@ impl AnalyticsView { false, cx, move || { - let mut tapes = held_tapes(&targets); + // One threshold for the whole table, read once: every row of one load splits + // its window the same way. + let long_position_ms = moon_core::market::trade_replay::long_position_ms(); + let mut tapes = held_tapes(&targets, long_position_ms); let mut rows: Vec = targets .into_iter() .map(|(deal, address)| DealRow { @@ -332,7 +335,7 @@ impl AnalyticsView { .get(&row.deal.report_uid) .cloned() .unwrap_or_default(); - replay_row(row, &defaults, lines); + replay_row(row, &defaults, lines, long_position_ms); } } rows @@ -365,7 +368,10 @@ impl AnalyticsView { /// The held tape of every target, asked from the worker in one batch and collected in order. /// A query the worker did not answer in time, or one cancelled by a scope change, is absent. -fn held_tapes(targets: &[(Deal, Arc)]) -> HashMap { +fn held_tapes( + targets: &[(Deal, Arc)], + long_position_ms: i64, +) -> HashMap { let deadline = std::time::Instant::now() + HELD_ANSWER_WAIT; let asked: Vec<( i64, @@ -374,7 +380,7 @@ fn held_tapes(targets: &[(Deal, Arc)]) -> HashMap { )> = targets .iter() .filter_map(|(deal, address)| { - let (rx, spans) = ask_held(address, deal)?; + let (rx, spans) = ask_held(address, deal, long_position_ms)?; Some((deal.report_uid, rx, spans)) }) .collect(); @@ -403,14 +409,18 @@ fn held_tapes(targets: &[(Deal, Arc)]) -> HashMap { } /// One held query sent, with the spans it asked for; the answer arrives on the receiver. +/// `long_position_ms` is the caller's — a queued row's own window's, or one read for a whole +/// table — so the split is the one every other stage of that row used. fn ask_held( address: &RowAddress, deal: &Deal, + long_position_ms: i64, ) -> Option<( mpsc::Receiver, Coverage, )> { - let window = replay_window_ms(deal.buy_ms, deal.close_ms, model_margin_ms())?; + let mut window = replay_window_ms(deal.buy_ms, deal.close_ms, model_margin_ms())?; + window.long_position_ms = long_position_ms; let spans = window.focus_spans(); let (reply, rx) = mpsc::channel(); query_held(TickQuery { @@ -503,8 +513,12 @@ type HeldTape = (Vec, Coverage, Coverage); /// The held prints of one deal's window, through the worker. `None` when the worker did not /// answer in time. -pub(super) fn held_tape(address: &RowAddress, deal: &Deal) -> Option { - let (rx, spans) = ask_held(address, deal)?; +pub(super) fn held_tape( + address: &RowAddress, + deal: &Deal, + long_position_ms: i64, +) -> Option { + let (rx, spans) = ask_held(address, deal, long_position_ms)?; let answer = rx.recv_timeout(HELD_ANSWER_WAIT).ok()?; Some((answer.ticks, answer.covered, spans)) } @@ -527,12 +541,18 @@ fn unservable_status(address: &RowAddress, deal: &Deal, now_ms: i64) -> Option, lines: ArchivedLines) { +/// without an address is left as it is. `long_position_ms` is the row's own threshold — see +/// [`ask_held`]. +pub(super) fn replay_row( + row: &mut DealRow, + defaults: &HashMap, + lines: ArchivedLines, + long_position_ms: i64, +) { let tape = row .address .as_ref() - .and_then(|address| held_tape(address, &row.deal)); + .and_then(|address| held_tape(address, &row.deal, long_position_ms)); replay_row_with(row, defaults, lines, tape); } diff --git a/crates/moon-ui-gpui/src/settings/storage.rs b/crates/moon-ui-gpui/src/settings/storage.rs index 5bec8386..634c66a5 100644 --- a/crates/moon-ui-gpui/src/settings/storage.rs +++ b/crates/moon-ui-gpui/src/settings/storage.rs @@ -225,6 +225,21 @@ impl SettingsView { } } + /// Moves the minutes a position must be held to count as long, clamped to + /// `LONG_POSITION_MIN_RANGE`, and updates live state and storage.toml. The cleanup's count + /// moves with it: a long position claims its two ends, a short one its whole length. + fn adjust_long_position_min(&mut self, delta: i32, cx: &mut Context) { + let current = self.storage.cfg.trade_replay.long_position_min as i32; + let v = storage_cfg::clamp_long_position_min((current + delta).max(0) as u32); + if self.storage.cfg.trade_replay.long_position_min != v { + self.storage.cfg.trade_replay.long_position_min = v; + moon_core::market::trade_replay::set_long_position_min(v); + storage_cfg::save(&self.storage.cfg); + self.storage_cleanup_refresh(cx); + cx.notify(); + } + } + /// The stepper's label for a margin: whole seconds under a minute, whole minutes from /// there — every step of `TRADE_MARGIN_STEPS_S` is one or the other. fn trades_margin_label(secs: u32) -> String { @@ -252,6 +267,8 @@ impl SettingsView { let trades_max_mb = self.storage.cfg.trade_replay.max_mb; let trades_margin_s = self.storage.cfg.trade_replay.margin_s; let autoload_missing = self.storage.cfg.trade_replay.autoload_missing; + let long_position_min = self.storage.cfg.trade_replay.long_position_min; + let cleanup_at_startup = self.storage.cfg.trade_replay.cleanup_at_startup; let size_line = |sz: Option<(u64, u64)>| -> String { match sz { @@ -503,6 +520,27 @@ impl SettingsView { )), ) .child(hint(t!("storage.trades_margin_hint").to_string())) + .child( + h_flex() + .flex_wrap() + .gap(design::ui_px(cx, 8.0)) + .items_center() + .child( + div() + .text_color(rgba_from(p.text, 1.0)) + .child(t!("storage.trades_long_position").to_string()), + ) + .child(self.stepper_controls( + cx, + "trades-long-position-min", + true, + t!("storage.trades_min", min = long_position_min).to_string(), + 1, + 5, + Self::adjust_long_position_min, + )), + ) + .child(hint(t!("storage.trades_long_position_hint").to_string())) // The Entry/Exit axis' tape autoload: a live cell in moon-core, read by the // coordination tick, so a flip needs no restart. .child( @@ -520,6 +558,24 @@ impl SettingsView { } })), ) + // The startup cleanup: read once per launch by the coordination tick, so the flip + // takes effect at the next launch — which is what "at startup" says. + .child( + moon_ui::MoonCheckbox::new("trades-cleanup-at-startup") + .checked(cleanup_at_startup) + .label(t!("storage.trades_cleanup_at_startup").to_string()) + .description(t!("storage.trades_cleanup_at_startup_hint").to_string()) + .on_change(cx.listener(|this, v: &bool, _, cx| { + let v = *v; + if this.storage.cfg.trade_replay.cleanup_at_startup != v { + this.storage.cfg.trade_replay.cleanup_at_startup = v; + moon_core::market::trade_replay::set_cleanup_at_startup(v); + storage_cfg::save(&this.storage.cfg); + cx.notify(); + } + })), + ) + .child(self.trades_cleanup_controls(cx, p, busy)) .child( h_flex() .flex_wrap() diff --git a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs index aaf14232..f4e02102 100644 --- a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs +++ b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs @@ -34,10 +34,11 @@ use crate::design; use moon_core::db::ReportAxis; use moon_core::db::ReportStamp; use moon_core::db::tape_owners::{TapeOwner, read_tape_owners}; -use moon_core::db::tuner::ticks::{ORDER_WAIT_CAP_MS, model_window_at, order_open_at}; use moon_core::market::MarketDataSource; use moon_core::market::trade_replay::trade_cache::{self, Inventory, KeepMap, TrimReport}; -use moon_core::market::trade_replay::{Coverage, long_position_ms, margin_ms, worker}; +use moon_core::market::trade_replay::{ + Coverage, long_position_ms, model_margin_ms, replay_window_ms, worker, +}; use moon_core::symbol::Exchange; /// What one pass found — the preview's numbers, or the apply's. @@ -71,30 +72,28 @@ pub(super) struct CleanupContext { /// What a claim is sized with, read once per pass so every row of it is judged alike whatever /// the Storage tab does meanwhile: the margin the tuner's fetch and the close-time capture ask -/// for (`[trade_replay] margin_s`, whose floor is the model's pad), and the length from which a -/// position is walked as its two ends. +/// for (the chart's `[trade_replay] margin_s` floored to the model's two pads), and the length +/// from which a position is walked as its two ends. #[derive(Clone, Copy, Debug)] struct Margins { - margin_ms: i64, + model_ms: i64, long_position_ms: i64, } impl Margins { fn live() -> Self { Self { - margin_ms: margin_ms(), + model_ms: model_margin_ms(), long_position_ms: long_position_ms(), } } - /// How far, in seconds, a row's claim can reach past its own stamps — the margin, and before - /// the entry the entry order's life on top of it (`ORDER_WAIT_CAP_MS`), rounded up. A row - /// that opened this much after the file's last print still claims prints inside the file, so - /// the replica is read that much wider than the file's range. The order's life reaches only - /// before the entry: on the other bound the reach is wider than any claim, and the few rows - /// it adds claim nothing. + /// How far, in seconds, a row's claim can reach past its own stamps — the margin, rounded + /// up. A row that opened this much after the file's last print, or closed this much before + /// its first, still claims prints inside the file, so the replica is read that much wider + /// than the file's range. fn reach_s(self) -> i64 { - (self.margin_ms + ORDER_WAIT_CAP_MS).div_euclid(1_000) + 1 + self.model_ms.div_euclid(1_000) + 1 } } @@ -232,19 +231,12 @@ fn build_keep( if *by_name_only { preview.by_name += 1; } - for stamp in stamps(axis, owner) { - // The tuner's own window (`model_window_at`): from the entry order's creation where - // the row carries it, so the lead the tuner fetched for the order's life is claimed - // with the trade — split by the pass's own threshold, not one read a moment later. - let Some(window) = model_window_at( - order_open_at(stamp.buy_ms, stamp.buy_set_ms), - stamp.buy_ms, - stamp.close_ms, - margins.margin_ms, - margins.long_position_ms, - ) else { + for (open_ms, close_ms) in stamps(axis, owner) { + let Some(mut window) = replay_window_ms(open_ms, close_ms, margins.model_ms) else { continue; }; + // The pass's own snapshot, not what `replay_window_ms` read a moment later. + window.long_position_ms = margins.long_position_ms; let focus = window.focus_spans(); for key in keys.iter() { let coverage = keep.entry(key.clone()).or_insert_with(Coverage::none); @@ -261,42 +253,17 @@ fn build_keep( /// for the catalog. type Addresses = HashMap<(u64, String), (Vec<(String, String)>, bool)>; -/// One claim's stamps on one clock: the entry, the exit, and the entry order's creation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct ClaimStamps { - buy_ms: i64, - close_ms: i64, - buy_set_ms: Option, -} - -/// The row's stamps on the file's clock: through the core's measured offset, the way the trade -/// window and the close-time capture stamp their requests — and, when both stamps are -/// milliseconds, the raw values too, the way the tuner's fetch stamps its. Two claims where the -/// clocks disagree, so neither path's tape is cut as the other's excess. The order's creation is -/// a millisecond stamp on the entry's clock and moves with it. -fn stamps(axis: &ReportAxis, owner: &TapeOwner) -> Vec { - let (buy_ms, close_ms) = axis.stamp_pair_to_utc_ms(owner.buy, owner.close, owner.core_uid); - let raw_buy_ms = match owner.buy { - ReportStamp::Millis(buy) => Some(buy), - ReportStamp::Seconds(_) => None, - }; - let lifted = ClaimStamps { - buy_ms, - close_ms, - buy_set_ms: owner - .buy_set_ms - .zip(raw_buy_ms) - .map(|(set, raw)| set + (buy_ms - raw)), - }; +/// The row's entry and exit on the file's clock: through the core's measured offset, the way +/// the trade window and the close-time capture stamp their requests — and, when both stamps +/// are milliseconds, the raw values too, the way the tuner's fetch stamps its. Two claims where +/// the clocks disagree, so neither path's tape is cut as the other's excess. +fn stamps(axis: &ReportAxis, owner: &TapeOwner) -> Vec<(i64, i64)> { + let lifted = axis.stamp_pair_to_utc_ms(owner.buy, owner.close, owner.core_uid); let mut out = vec![lifted]; if let (ReportStamp::Millis(buy), ReportStamp::Millis(close)) = (owner.buy, owner.close) - && (buy, close) != (buy_ms, close_ms) + && (buy, close) != lifted { - out.push(ClaimStamps { - buy_ms: buy, - close_ms: close, - buy_set_ms: owner.buy_set_ms, - }); + out.push((buy, close)); } out } diff --git a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/startup.rs b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/startup.rs index 9b6b3724..701be540 100644 --- a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/startup.rs +++ b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/startup.rs @@ -1,9 +1,14 @@ //! The startup cleanup: the Storage tab's trade-tape cleanup run on the terminal's own //! initiative once the cores are up, behind `[trade_replay] cleanup_at_startup`. //! -//! Once per process, from the coordination tick. The switch is read on the first tick: on, the -//! cleanup is due after [`FIRST_DELAY`], for the cores to come up and report their catalogs, so -//! most rows resolve through the live catalog rather than by name; off, nothing +//! Once per process, from the coordination tick, the way the tape autoload runs +//! (`analytics::tuner::ticks::fetch::autoload`) — and BEFORE it: the cleanup keeps only what the +//! tuner's rows claim at the margin in force, the autoload then fetches what those rows still +//! lack, so nothing the autoload just paid the venues for is what the cleanup removes. The +//! autoload asks [`clear_for_autoload`] before its first pass and waits while the cleanup is +//! pending or running. The switch is read on the first tick: on, the cleanup is due after +//! [`FIRST_DELAY`], the same wait the autoload gives the cores to come up and report their +//! catalogs, so most rows resolve through the live catalog rather than by name; off, nothing //! runs until the next launch — "at startup" means at startup, and flipping the switch on //! mid-session must not rewrite the file the moment the checkbox is pressed. @@ -16,7 +21,8 @@ use super::CleanupContext; use crate::Backend; /// How long after the first tick the cleanup runs — for the cores to come up and report their -/// catalogs. +/// catalogs. The autoload's own first delay, kept equal on purpose: the autoload is due at the +/// same moment and yields to the cleanup, so the two do not add up. const FIRST_DELAY: Duration = Duration::from_secs(20); /// Where the startup cleanup stands. @@ -41,6 +47,12 @@ fn lock() -> std::sync::MutexGuard<'static, Phase> { .unwrap_or_else(std::sync::PoisonError::into_inner) } +/// Whether the tape autoload may start its first pass: the startup cleanup is over, or was +/// never going to run. +pub(crate) fn clear_for_autoload() -> bool { + *lock() == Phase::Done +} + /// The coordination tick's call: read the switch once, wait, run once. Cheap when nothing is /// due — a lock and a clock compare. pub(crate) fn tick(backend: &Backend, cx: &App) { diff --git a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs index b0035cd4..82bbd48d 100644 --- a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs +++ b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs @@ -7,12 +7,12 @@ use std::collections::HashMap; use std::sync::Arc; use super::{ - ClaimStamps, Inventory, KeepMap, Margins, OWNER_SLACK_S, ReportAxis, ReportStamp, TapeOwner, - build_keep, read_tape_owners, stamps, trade_cache, + Inventory, KeepMap, Margins, OWNER_SLACK_S, ReportAxis, ReportStamp, TapeOwner, build_keep, + read_tape_owners, stamps, trade_cache, }; const MARGINS: Margins = Margins { - margin_ms: 60_000, + model_ms: 60_000, long_position_ms: 5 * 60_000, }; @@ -24,7 +24,6 @@ fn owner(core_uid: u64, coin: &str, buy_ms: i64, close_ms: i64, kind: &str) -> T close: ReportStamp::Millis(close_ms), strategy_id: 42, sell_reason: "Sell Price".into(), - buy_set_ms: None, kind: kind.into(), } } @@ -48,10 +47,10 @@ fn spans(keep: &KeepMap, k: &(String, String)) -> Vec<(i64, i64)> { keep.get(k).map(|c| c.spans().to_vec()).unwrap_or_default() } -/// A row the catalog names claims its window's focus at the margin, on the market the catalog -/// named. +/// A row the catalog names claims its window's focus at the model's margin, on the market the +/// catalog named. #[test] -fn a_live_row_claims_at_the_margin() { +fn a_live_row_claims_at_the_model_margin() { let inv = inventory(&[("4:0", "ACEUSDT")]); let ace = owner(1, "ACE", 1_000_000, 1_010_000, "MoonShot"); let ben = owner(1, "BEN", 5_000_000, 5_010_000, "MoonShot"); @@ -98,45 +97,7 @@ fn overlapping_claims_are_one_stretch() { ); } -/// A row that carries its entry order's creation claims from there, as the tuner fetches it -/// (`model_window_at`) — for every kind the tuner runs on, a model or none; one whose creation -/// and position together outrun the long-position threshold claims from its fill, as before. -#[test] -fn a_row_with_its_orders_creation_claims_from_the_creation() { - let inv = inventory(&[("4:0", "ACEUSDT")]); - let mut hook = owner(1, "ACE", 1_000_000, 1_010_000, "MoonHook"); - hook.buy_set_ms = Some(1_000_000 - 120_000); - let (keep, _) = build_keep( - &inv, - &[&hook], - &ReportAxis::default(), - "es(), - MARGINS, - |_| Some(key("4:0", "ACEUSDT")), - ); - assert_eq!( - spans(&keep, &key("4:0", "ACEUSDT")), - vec![(1_000_000 - 120_000 - 60_000, 1_010_000 + 60_000)] - ); - let mut long = owner(1, "ACE", 1_000_000, 1_000_000 + 4 * 60_000, "MoonShot"); - long.buy_set_ms = Some(1_000_000 - 120_000); - let (keep, _) = build_keep( - &inv, - &[&long], - &ReportAxis::default(), - "es(), - MARGINS, - |_| Some(key("4:0", "ACEUSDT")), - ); - assert_eq!( - spans(&keep, &key("4:0", "ACEUSDT"))[0].0, - 1_000_000 - 60_000, - "creation to close outruns five minutes: the window opens at the fill" - ); -} - -/// A position held longer than five minutes claims its two ends, the margin on both sides of -/// each, not its middle. +/// A position held longer than five minutes claims its two ends, not its middle. #[test] fn a_long_position_claims_its_two_ends() { let inv = inventory(&[("4:0", "ACEUSDT")]); @@ -152,8 +113,8 @@ fn a_long_position_claims_its_two_ends() { assert_eq!( spans(&keep, &key("4:0", "ACEUSDT")), vec![ - (1_000_000 - 60_000, 1_000_000 + 60_000), - (4_600_000 - 60_000, 4_600_000 + 60_000) + (1_000_000 - 30_000, 1_000_000 + 30_000), + (4_600_000 - 30_000, 4_600_000 + 30_000) ] ); } @@ -228,16 +189,10 @@ fn a_row_off_the_file_is_unresolved() { /// the window and the capture use, and the raw one the tuner's fetch uses. #[test] fn stamps_claim_both_clocks_when_they_differ() { - let mut row = owner(1, "ACE", 1_000_000, 1_010_000, ""); - row.buy_set_ms = Some(990_000); - let claim = |buy_ms, close_ms, buy_set_ms| ClaimStamps { - buy_ms, - close_ms, - buy_set_ms, - }; + let row = owner(1, "ACE", 1_000_000, 1_010_000, ""); assert_eq!( stamps(&ReportAxis::default(), &row), - vec![claim(1_000_000, 1_010_000, Some(990_000))] + vec![(1_000_000, 1_010_000)] ); let axis = ReportAxis::from_measured( HashMap::from([( @@ -249,40 +204,32 @@ fn stamps_claim_both_clocks_when_they_differ() { )]), chrono_tz::UTC, ); - let (buy, close) = axis.stamp_pair_to_utc_ms(row.buy, row.close, 1); - assert_ne!((buy, close), (1_000_000, 1_010_000)); - // The creation moves with the entry's clock. - assert_eq!( - stamps(&axis, &row), - vec![ - claim(buy, close, Some(990_000 + (buy - 1_000_000))), - claim(1_000_000, 1_010_000, Some(990_000)) - ] - ); + let lifted = axis.stamp_pair_to_utc_ms(row.buy, row.close, 1); + assert_ne!(lifted, (1_000_000, 1_010_000)); + assert_eq!(stamps(&axis, &row), vec![lifted, (1_000_000, 1_010_000)]); let seconds = TapeOwner { buy: ReportStamp::Seconds(1_000), close: ReportStamp::Seconds(1_010), - buy_set_ms: None, ..row }; assert_eq!( stamps(&ReportAxis::default(), &seconds), - vec![claim(1_000_000, 1_010_000, None)] + vec![(1_000_000, 1_010_000)] ); } -/// The replica is read as far past the file's range as a claim reaches — the margin and an entry -/// order's longest replayed wait — rounded up to whole seconds. +/// The replica is read as far past the file's range as the wider margin reaches, rounded up +/// to whole seconds. #[test] -fn the_reach_is_the_margin_and_the_orders_wait_in_whole_seconds() { - assert_eq!(MARGINS.reach_s(), 661); +fn the_reach_is_the_wider_margin_in_whole_seconds() { + assert_eq!(MARGINS.reach_s(), 61); assert_eq!( Margins { - margin_ms: 7_200_000, + model_ms: 7_200_000, long_position_ms: 60_000 } .reach_s(), - 7_801 + 7_201 ); } @@ -343,8 +290,8 @@ fn probe_a_copied_data_dir() { .map(|o| (o.core_uid, "USDT".to_string())) .collect(); println!( - "[probe] margin: {} ms, long position from {} ms", - margins.margin_ms, + "[probe] margin: model {} ms, long position from {} ms", + margins.model_ms, moon_core::market::trade_replay::long_position_ms() ); for apply in [false, true] { diff --git a/crates/moon-ui-gpui/src/startup/boot.rs b/crates/moon-ui-gpui/src/startup/boot.rs index 72409d5d..4bf8930b 100644 --- a/crates/moon-ui-gpui/src/startup/boot.rs +++ b/crates/moon-ui-gpui/src/startup/boot.rs @@ -709,8 +709,11 @@ pub(super) fn boot(cfg: AppConfig, input: BootInput, cx: &mut App) { } } b.tick_telegram(cx); - // The tape autoload of the tuner's Entry/Exit axis: a switch read and a - // clock compare on every tick, a background pass when one is due. + // The startup cleanup of the trade tape, then the tape autoload of the + // tuner's Entry/Exit axis — in that order, the autoload waits for the + // cleanup: a switch read and a clock compare each on every tick, a + // background pass when one is due. + crate::settings::trades_cleanup_startup::tick(b, cx); crate::analytics::tape_autoload::tick(b, cx); // A core removed from the session cannot answer what was asked of it; the // drain edge does not fire for a removal, so the slow tick settles those. diff --git a/locales/storage.yml b/locales/storage.yml index 889ee0e7..c097aba2 100644 --- a/locales/storage.yml +++ b/locales/storage.yml @@ -129,17 +129,65 @@ storage.trades_min: en: "%{min} min" es: "%{min} min" storage.trades_margin_hint: - ru: "Сколько трейдов брать с каждого конца сделки: у короткой — до входа и после выхода, у долгой (дольше 5 мин) — столько же вокруг входа и вокруг выхода, середина свечами. Ступени от 5 с до 120 мин; тюнер берёт не меньше 30 с с каждого края." - en: "Prints taken at each end of a trade: before the entry and after the exit of a short one; the same stretch centred on the entry and on the exit of a long one (over 5 min), candles between. Steps from 5 s to 120 min; the tuner takes at least 30 s at each end." - es: "Operaciones tomadas en cada extremo de una posición: antes de la entrada y después de la salida en una corta; el mismo tramo centrado en la entrada y en la salida en una larga (más de 5 min), velas entre ambas. Pasos de 5 s a 120 min; el afinador toma al menos 30 s en cada extremo." + ru: "С каждого края сделки; у долгой — вокруг входа и вокруг выхода, середина свечами. Тюнер берёт не меньше 30 с." + en: "At each end of a trade; on a long one, around the entry and around the exit, candles between. The tuner takes at least 30 s." + es: "En cada extremo de una posición; en una larga, alrededor de la entrada y de la salida, velas entre ambas. El afinador toma al menos 30 s." +storage.trades_long_position: + ru: "Долгая сделка от" + en: "Long trade from" + es: "Posición larga desde" +storage.trades_long_position_hint: + ru: "Дольше — трейды только вокруг входа и выхода." + en: "Held longer — prints around the entry and the exit only." + es: "Más larga — operaciones solo alrededor de la entrada y la salida." storage.trades_autoload: ru: "Подгружать недостающую ленту при старте" en: "Fetch the missing tape at startup" es: "Descargar la cinta que falta al arrancar" storage.trades_autoload_hint: - ru: "Когда ядра поднялись, терминал сам запрашивает у бирж трейды недавних закрытых сделок стратегий с мс-штампами — тех, что закрылись, пока терминал не работал и захват при закрытии их не застал. Только сделки, по которым тюнер может считать: без фандинга, ликвидаций, объединённых и ручных продаж. Окно — сколько биржа ещё отдаёт (Binance фьючерсы 48 ч, Gate spot 30 дней, OKX и Bitget 90, не дальше 30 дней). Bybit и Hyperliquid публичной истории трейдов не дают. Тратит лимит публичных запросов; идёт той же очередью, что «Прогрузить трейды» в тюнере, и останавливается той же кнопкой." - en: "Once the cores are up the terminal asks the venues for the prints of recent closed strategy trades with millisecond stamps — the ones that closed while it was not running and the close-time capture missed. Only trades the tuner can be run on: no funding, liquidations, joined or manual sells. The window is what the venue still serves (Binance futures 48 h, Gate spot 30 days, OKX and Bitget 90, never past 30 days). Bybit and Hyperliquid serve no public trade history. Spends the public request budget; runs in the same queue as \"Fetch trades\" in the tuner and stops with the same button." - es: "Cuando los núcleos están arriba, el terminal pide a las bolsas las operaciones de las posiciones de estrategias cerradas recientes con marcas de milisegundos — las que cerraron mientras no estaba en marcha y la captura al cierre no vio. Solo operaciones sobre las que el ajustador puede calcular: sin funding, liquidaciones, ventas unidas ni manuales. La ventana es lo que la bolsa aún sirve (futuros Binance 48 h, Gate spot 30 días, OKX y Bitget 90, nunca más de 30 días). Bybit e Hyperliquid no dan historial público. Gasta el límite de peticiones públicas; va en la misma cola que «Cargar trades» del ajustador y se para con el mismo botón." + ru: "Трейды сделок стратегий, закрытых пока терминал не работал, — насколько биржа ещё отдаёт (Binance фьючерсы 48 ч, остальные до 30 дней; Bybit и Hyperliquid истории не дают). Тратит лимит запросов; останавливается кнопкой тюнера." + en: "Prints of strategy trades that closed while the terminal was not running — as far back as the venue still serves (Binance futures 48 h, others up to 30 days; Bybit and Hyperliquid serve no history). Spends the request budget; stops with the tuner's button." + es: "Operaciones de posiciones de estrategias cerradas mientras el terminal no estaba en marcha — hasta donde la bolsa aún sirve (futuros Binance 48 h, el resto hasta 30 días; Bybit e Hyperliquid no dan historial). Gasta el límite de peticiones; se para con el botón del afinador." +storage.trades_cleanup_at_startup: + ru: "Чистка при старте" + en: "Clean up at startup" + es: "Limpiar al arrancar" +storage.trades_cleanup_at_startup_hint: + ru: "То же, что кнопка «Чистка», перед подгрузкой ленты. Со следующего запуска." + en: "Same as the \"Clean up\" button, before the tape fetch. From the next launch." + es: "Lo mismo que el botón «Limpiar», antes de descargar la cinta. Desde el próximo arranque." +storage.trades_cleanup: + ru: "Чистка" + en: "Clean up" + es: "Limpiar" +storage.trades_cleanup_hint: + ru: "Оставляет трейды сделок стратегий, закрытых самой стратегией (что читает тюнер), в пределах ступени «Трейды вокруг сделки». Ручные продажи, фандинг, ликвидации и трейды без сделки в отчёте уходят. Файл сжимается сразу." + en: "Keeps the prints of strategy trades the strategy closed itself (what the tuner reads), within the \"Prints around a trade\" step. Manual sells, funding, liquidations and prints with no trade in the report go. The file is compacted at once." + es: "Conserva las operaciones de posiciones de estrategias cerradas por la propia estrategia (lo que lee el afinador), dentro del paso «Operaciones alrededor de una posición». Ventas manuales, funding, liquidaciones y operaciones sin posición en el informe se van. El archivo se compacta al instante." +storage.trades_cleanup_preview: + ru: "удалится ≈ %{prints} трейдов, освободится ≈ %{size}" + en: "≈ %{prints} prints go, ≈ %{size} freed" + es: "se borrarán ≈ %{prints} operaciones, se liberarán ≈ %{size}" +storage.trades_cleanup_empty: + ru: "нечего удалять" + en: "nothing to delete" + es: "nada que borrar" +storage.trades_cleanup_pending: + ru: "считаю…" + en: "counting…" + es: "contando…" +storage.trades_cleanup_failed: + ru: "не посчитать — %{err}" + en: "could not count — %{err}" + es: "no se pudo contar — %{err}" +storage.trades_cleanup_done: + ru: "удалено %{prints} трейдов, освобождено ≈ %{size}" + en: "%{prints} prints deleted, ≈ %{size} freed" + es: "%{prints} operaciones borradas, ≈ %{size} liberados" +storage.op_cleanup: + ru: "Чистка" + en: "Cleanup" + es: "Limpieza" storage.trades_hint: ru: "Файл можно удалить при закрытом терминале." en: "The file can be deleted while the terminal is closed." From 139683512183c6dff9f824648beb800d70d7710d Mon Sep 17 00:00:00 2001 From: guyverino Date: Mon, 21 Sep 2026 15:02:43 +0200 Subject: [PATCH 13/51] fix(trade-replay): walk Gate futures trades by time and id, not by offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=,,,`, walks one slice against the live venue with the real pager (allowed in the diagnostics contract as a by-hand test switch). --- .../src/market/trade_replay/rest/gateio.rs | 70 +++++++- .../market/trade_replay/rest/gateio/tests.rs | 149 +++++++++++++++++- .../src/market/trade_replay/venue_caps.rs | 6 +- .../src/market/trade_replay/worker/tests.rs | 90 +++++++++++ 4 files changed, 301 insertions(+), 14 deletions(-) diff --git a/crates/moon-core/src/market/trade_replay/rest/gateio.rs b/crates/moon-core/src/market/trade_replay/rest/gateio.rs index 933c0ffc..f9c7f1e4 100644 --- a/crates/moon-core/src/market/trade_replay/rest/gateio.rs +++ b/crates/moon-core/src/market/trade_replay/rest/gateio.rs @@ -247,7 +247,28 @@ pub(super) fn fetch_trades( true => "contract", false => "currency_pair", }; - let (from_s, to_s) = trade_window_seconds(from_ms, to_ms); + // A futures walk is paged back by TIME: the next page ends at the second of the oldest + // row seen (one past it, as `trade_window_seconds` widens every `to`), and the rows of + // that second the page before already took are dropped by id in `parse_futures_trades`. + // A second too dense for a page is drained by `offset` with `from`/`to` pinned to it + // (`TradeCursor::Within`) — the one use of `offset`. One `from` and one `to` in the query, + // decided here: `query` appends, and a second `to` would leave the venue to pick either. + let (from_s, to_s, offset) = match (futures, cursor) { + (true, Some(TradeCursor::Before { boundary_ms, .. })) => { + let (from_s, to_s) = trade_window_seconds(from_ms, boundary_ms.min(to_ms)); + (from_s, to_s, None) + } + ( + true, + Some(TradeCursor::Within { + second_s, offset, .. + }), + ) => (second_s, second_s + 1, Some(offset)), + _ => { + let (from_s, to_s) = trade_window_seconds(from_ms, to_ms); + (from_s, to_s, None) + } + }; let mut request = agent .get(route.url()) .query(market_param, market) @@ -386,9 +407,21 @@ fn parse_spot_trade_row(row: &Value) -> Option { /// where spot's `docs/Trade.md` types it `str` — and the recorded response agrees. Spot's /// `create_time_ms` is a millisecond string; the two parsers are deliberately not shared. /// -/// A FULL page is ALWAYS treated as incomplete regardless of any other signal: this endpoint -/// truncates SILENTLY at `limit` with no error, so a full page means "ask again", never "that was -/// all" — see [`super::super::rest::TradePage::next`]'s own doc for why that rule is frozen. +/// A FULL page is NEVER accepted as complete: this endpoint truncates SILENTLY at `limit` with +/// no error, so a full page means "ask again", never "that was all" — see +/// [`super::super::rest::TradePage::next`]'s own doc for why that rule is frozen. +/// +/// # Paged by time and id; one second at a time by `offset` +/// +/// The next page is asked up to the second of this page's OLDEST row ([`TradeCursor::Before`]), +/// so it holds that second again: the rows of it this page took are told apart by trade id — +/// every row at or above the oldest id taken is dropped, and on a continuation page a row +/// with no id is dropped too, or it would be taken again on every page — and the rest are +/// new. A full page that brought NOTHING new is a second holding more prints than a page, +/// which `to` in whole seconds cannot enter: the walk drains that one second by `offset` +/// ([`TradeCursor::Within`]) until a short page, then goes back to time up to the second's +/// own start. A drain page that brought nothing new and is full still moves the offset — the +/// rows behind it are the venue's, and the page budget bounds the walk. /// /// Args: /// body: Decoded response. @@ -406,9 +439,32 @@ pub(super) fn parse_futures_trades( let rows = body.as_array().ok_or_else(|| { FetchError::Transient("gate: futures response is not an array".to_string()) })?; - // The `size: 0` rows a small contract prints between real fills are split off first — see - // `split_no_fill`; this is the route they were recorded on. - let (fills, no_fill) = split_no_fill(rows, "size"); + let full = rows.len() >= max_rows; + // Rows the page before already took: at or above the oldest id it held. + let below_id = match cursor { + Some(TradeCursor::Before { below_id, .. } | TradeCursor::Within { below_id, .. }) => { + below_id + } + _ => u64::MAX, + }; + let continuing = matches!( + cursor, + Some(TradeCursor::Before { .. } | TradeCursor::Within { .. }) + ); + let raw_len = rows.len(); + let rows: Vec<&Value> = rows + .iter() + .filter(|row| match futures_row_id(row) { + Some(id) => id < below_id, + None => !continuing, + }) + .collect(); + // The `size: 0` rows a small contract prints between real fills are split off first — the + // rule of `split_no_fill`, over the rows kept; this is the route they were recorded on. + let (fills, empty): (Vec<&Value>, Vec<&Value>) = rows + .iter() + .partition(|row| row.get("size").and_then(cell_number) != Some(0.0)); + let no_fill = empty.len(); let ticks: Vec = fills .iter() .filter_map(|row| parse_futures_trade_row(row)) diff --git a/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs b/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs index 0d287493..dce46b68 100644 --- a/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs +++ b/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs @@ -395,17 +395,154 @@ fn gate_futures_trades_skip_a_zero_size_row_without_refusing_the_page() { } /// `rest/gateio.rs:parse_futures_trades` on a FULL page of nothing but `size: 0` rows: the -/// route never accepts a full page as complete, so the cursor must still advance by the -/// venue's row count — a dead stretch on a small contract is walked through, not refused and -/// not mistaken for the end of the tape. +/// route never accepts a full page as complete, so the cursor must still move onto the page's +/// oldest row — a dead stretch on a small contract is walked through, not refused and not +/// mistaken for the end of the tape. #[test] fn gate_futures_page_of_only_zero_size_rows_is_empty_and_still_pages_on() { let mut body = fixture("futures_trades_zero_size"); for row in body.as_array_mut().expect("array") { row["size"] = serde_json::json!(0); } - let page = parse_futures_trades(&body, 6, Some(TradeCursor::Offset(6))) - .expect("a page of zero-size rows parses"); + let rows = body.as_array().expect("array"); + let oldest_id = rows + .iter() + .map(|r| r["id"].as_u64().unwrap()) + .min() + .unwrap(); + let page = parse_futures_trades( + &body, + 6, + Some(TradeCursor::Before { + boundary_ms: i64::MAX, + below_id: u64::MAX, + }), + ) + .expect("a page of zero-size rows parses"); assert!(page.ticks.is_empty()); - assert_eq!(page.next, Some(TradeCursor::Offset(12))); + assert!( + matches!(page.next, Some(TradeCursor::Before { below_id, .. }) if below_id == oldest_id) + ); +} + +/// A full page hands back a `Before` cursor at its OLDEST row (time and id), and the next page +/// — asked up to that row's second, so it holds that second again — drops every row at or +/// above that id and keeps the rest. Three rows against a cap of three is a full page. +#[test] +fn gate_futures_full_page_pages_back_by_time_and_drops_the_rows_already_taken() { + let body = fixture("futures_trades"); + let page = parse_futures_trades(&body, 3, None).expect("full page parses"); + assert_eq!(page.ticks.len(), 3); + let Some(TradeCursor::Before { + boundary_ms, + below_id, + }) = page.next + else { + panic!("a full page pages on: {:?}", page.next); + }; + assert_eq!(boundary_ms, 1_789_726_950_929, "the oldest row's stamp"); + assert_eq!(below_id, 29202, "the oldest row's id"); + // The next page, as the venue answers `to=1789726951`: the two rows of that second again + // (ids 29203, 29202 — already held) plus one older row. + let next_body = serde_json::json!([ + {"id": 29203, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, + {"id": 29202, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, + {"id": 29201, "contract": "CATE_USDT", "create_time": 1789726948.100, "create_time_ms": 1789726948.100, "size": -2, "price": "0.09100"} + ]); + let next = parse_futures_trades(&next_body, 3, page.next).expect("next page parses"); + assert_eq!( + next.ticks.len(), + 1, + "only the row below the id already held" + ); + assert_eq!(next.ticks[0].time_ms, 1_789_726_948_100.0); + assert!( + matches!( + next.next, + Some(TradeCursor::Before { + below_id: 29201, + boundary_ms: 1_789_726_948_100 + }) + ), + "{:?}", + next.next + ); + // A full page whose every row is already held is a second denser than a page: the walk + // drains that second by offset, from its start. + let dense = serde_json::json!([ + {"id": 29203, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, + {"id": 29202, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, + {"id": 29204, "contract": "CATE_USDT", "create_time": 1789726954.306, "create_time_ms": 1789726954.306, "size": -1, "price": "0.09058"} + ]); + let drain = parse_futures_trades(&dense, 3, page.next).expect("parses"); + assert!(drain.ticks.is_empty()); + assert_eq!( + drain.next, + Some(TradeCursor::Within { + second_s: 1_789_726_950, + offset: 0, + below_id: 29202, + low_id: 29202 + }) + ); + // A full drain page moves the offset by the venue's row count and keeps only the new + // rows; the boundary id follows the oldest new row. + let drain_page = serde_json::json!([ + {"id": 29202, "contract": "CATE_USDT", "create_time": 1789726950.929, "create_time_ms": 1789726950.929, "size": 1, "price": "0.09102"}, + {"id": 29200, "contract": "CATE_USDT", "create_time": 1789726950.500, "create_time_ms": 1789726950.500, "size": 3, "price": "0.09101"}, + {"id": 29199, "contract": "CATE_USDT", "create_time": 1789726950.400, "create_time_ms": 1789726950.400, "size": 3, "price": "0.09101"} + ]); + let drained = parse_futures_trades(&drain_page, 3, drain.next).expect("parses"); + assert_eq!(drained.ticks.len(), 2); + assert_eq!( + drained.next, + Some(TradeCursor::Within { + second_s: 1_789_726_950, + offset: 3, + below_id: 29202, + low_id: 29199 + }), + "the boundary stays, the low id follows the drain" + ); + // Oldest-first inside the second would not lose a row: the boundary does not follow. + let ascending = serde_json::json!([ + {"id": 29195, "contract": "CATE_USDT", "create_time": 1789726950.050, "create_time_ms": 1789726950.050, "size": 1, "price": "0.09100"}, + {"id": 29196, "contract": "CATE_USDT", "create_time": 1789726950.060, "create_time_ms": 1789726950.060, "size": 1, "price": "0.09100"}, + {"id": 29197, "contract": "CATE_USDT", "create_time": 1789726950.070, "create_time_ms": 1789726950.070, "size": 1, "price": "0.09100"} + ]); + let asc = parse_futures_trades(&ascending, 3, drained.next).expect("parses"); + assert_eq!(asc.ticks.len(), 3); + // A short drain page ends the second: back to time, up to the second's own start, so the + // next `to` is the second itself and the rows of it are not asked a third time. + let tail = serde_json::json!([ + {"id": 29198, "contract": "CATE_USDT", "create_time": 1789726950.100, "create_time_ms": 1789726950.100, "size": 1, "price": "0.09100"} + ]); + let ended = parse_futures_trades(&tail, 3, drained.next).expect("parses"); + assert_eq!(ended.ticks.len(), 1); + assert_eq!( + ended.next, + Some(TradeCursor::Before { + boundary_ms: 1_789_726_949_999, + below_id: 29198 + }) + ); + assert_eq!(trade_window_seconds(0, 1_789_726_949_999).1, 1_789_726_950); + // On a continuation page a row without an id is dropped, not taken again. + let no_id = serde_json::json!([ + {"contract": "CATE_USDT", "create_time": 1789726948.100, "create_time_ms": 1789726948.100, "size": -2, "price": "0.09100"} + ]); + let short = parse_futures_trades(&no_id, 3, page.next).expect("parses"); + assert!(short.ticks.is_empty()); + assert_eq!(short.next, None); + let first = parse_futures_trades(&no_id, 3, None).expect("parses"); + assert_eq!(first.ticks.len(), 1, "a first page keeps it"); +} + +/// `rest/gateio.rs:fetch_trades` sends the cursor's boundary as `to`, one second past the +/// boundary's own second, and never an `offset`. +#[test] +fn gate_futures_before_cursor_moves_to_onto_the_boundary_second() { + // `trade_window_seconds` is the one rule for `to`; the cursor reuses it on its boundary. + let (_, to) = trade_window_seconds(0, 1_789_726_950_929); + assert_eq!(to, 1_789_726_951); } diff --git a/crates/moon-core/src/market/trade_replay/venue_caps.rs b/crates/moon-core/src/market/trade_replay/venue_caps.rs index c3810791..e83ae06e 100644 --- a/crates/moon-core/src/market/trade_replay/venue_caps.rs +++ b/crates/moon-core/src/market/trade_replay/venue_caps.rs @@ -342,9 +342,13 @@ impl TradeRoute { Self::BinanceUsdMAggTrades | Self::BinanceCoinMAggTrades => { std::time::Duration::from_millis(650) } + // Not a weight limit: under back-to-back requests the futures trades endpoint + // answered a repeat of the previous page for a changed `offset` (2026-09-21, see + // the route table), and never did 300 ms apart; a page is 1 000 rows, so the + // floor costs a busy minute of tape a third of a second. + Self::GateFuturesTrades => std::time::Duration::from_millis(350), Self::BinanceSpotAggTrades | Self::GateSpotTrades - | Self::GateFuturesTrades | Self::BitgetSpotFills | Self::BitgetMixFills | Self::OkxHistoryTrades => super::gate::MIN_INTERVAL, diff --git a/crates/moon-core/src/market/trade_replay/worker/tests.rs b/crates/moon-core/src/market/trade_replay/worker/tests.rs index 628b3153..88303ea0 100644 --- a/crates/moon-core/src/market/trade_replay/worker/tests.rs +++ b/crates/moon-core/src/market/trade_replay/worker/tests.rs @@ -1155,3 +1155,93 @@ fn a_tiles_reader_gets_the_ring_as_core_tiles_the_walk_no_longer_asks_for() { assert!(!ReplayIntent::Chart.files_core()); assert!(ReplayIntent::Model.files_core()); } + +/// The real pager over the real venue, by hand: `MOON_TICKS_PROBE=GateFuturesTrades,GSTOCKBSC_USDT,,` +/// walks that one slice and prints what came back — rows, distinct prints, the largest holes — +/// so a hole or a duplicate in the store can be told apart from one the pager makes today. +#[test] +#[ignore = "asks the venue over the network; run by hand"] +fn probe_one_slice_against_the_venue() { + let Ok(spec) = std::env::var("MOON_TICKS_PROBE") else { + eprintln!("MOON_TICKS_PROBE is not set; nothing to do"); + return; + }; + let parts: Vec<&str> = spec.split(',').collect(); + let [route, market, from_ms, to_ms] = parts[..] else { + panic!("MOON_TICKS_PROBE=,,,"); + }; + let route = match route { + "GateFuturesTrades" => TradeRoute::GateFuturesTrades, + "GateSpotTrades" => TradeRoute::GateSpotTrades, + "OkxHistoryTrades" => TradeRoute::OkxHistoryTrades, + "BinanceUsdMAggTrades" => TradeRoute::BinanceUsdMAggTrades, + other => panic!("unknown route {other}"), + }; + let (from_ms, to_ms): (i64, i64) = (from_ms.parse().unwrap(), to_ms.parse().unwrap()); + let plan = TickPlan { + slices: vec![(from_ms, to_ms)], + trade_len: 1, + focus_len: 1, + }; + let agent = rest::agent(); + let mut observer = FakeObserver::default(); + let verdict = paginate_ticks( + route, + &plan, + TICK_BUDGET, + TICK_PAGE_BUDGET, + || false, + |_| false, + &mut observer, + |from, to, cursor| { + let page = rest::fetch_trades(&agent, route, market, from, to, cursor); + if let Ok(page) = &page { + let (lo, hi) = page.ticks.iter().fold((i64::MAX, i64::MIN), |(lo, hi), t| { + (lo.min(t.time_ms as i64), hi.max(t.time_ms as i64)) + }); + eprintln!( + "PROBE page cursor={cursor:?} rows={} t=+{}..+{} ms next={:?}", + page.ticks.len(), + lo.saturating_sub(from_ms), + hi.saturating_sub(from_ms), + page.next + ); + } + page + }, + ); + let TickVerdict::Ready(harvest) = verdict else { + panic!("abandoned: {verdict:?}"); + }; + let mut keys: Vec<(i64, u32, u32, u8)> = harvest + .ticks + .iter() + .map(|t| { + ( + t.time_ms as i64, + t.price.to_bits(), + t.qty.to_bits(), + t.side as u8, + ) + }) + .collect(); + let rows = keys.len(); + keys.sort_unstable(); + keys.dedup(); + let mut times: Vec = keys.iter().map(|k| k.0).collect(); + times.dedup(); + let mut gaps: Vec<(i64, i64)> = times + .windows(2) + .map(|w| (w[1] - w[0], w[0] - from_ms)) + .collect(); + gaps.sort_unstable_by(|a, b| b.cmp(a)); + eprintln!( + "PROBE {market}: pages={} rows={rows} distinct={} covered={} complete={} stop={:?}\nPROBE largest gaps (ms, at +ms): {:?}", + observer.paces, + keys.len(), + harvest.covered, + harvest.complete, + harvest.stop, + &gaps[..gaps.len().min(5)] + ); +} From 9a5352cabd6faea343aa57b7d10fa2df283c9696 Mon Sep 17 00:00:00 2001 From: guyverino Date: Mon, 21 Sep 2026 15:02:43 +0200 Subject: [PATCH 14/51] feat(tuner): judge the exit by the line's level at the close, take the line's start from the archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/db/tuner/ticks/deals.rs | 9 +- crates/moon-core/src/db/tuner/ticks/entry.rs | 10 +- crates/moon-core/src/db/tuner/ticks/exit.rs | 93 +++++++- crates/moon-core/src/db/tuner/ticks/line.rs | 56 ++++- .../src/db/tuner/ticks/line/tests.rs | 122 +++++++++- crates/moon-core/src/db/tuner/ticks/mod.rs | 68 +++++- crates/moon-core/src/db/tuner/ticks/mshot.rs | 79 ++++++- crates/moon-core/src/db/tuner/ticks/params.rs | 1 + crates/moon-core/src/db/tuner/ticks/search.rs | 7 +- .../src/db/tuner/ticks/search/tests.rs | 5 +- .../src/db/tuner/ticks/stats/tests.rs | 3 + crates/moon-core/src/db/tuner/ticks/tests.rs | 223 +++++++++++++++++- .../src/db/tuner/ticks/tests/real_data.rs | 45 ++-- crates/moon-core/src/db/tuner/ticks/verify.rs | 166 ++++++++++--- 14 files changed, 802 insertions(+), 85 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index cec76768..a210c542 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -112,7 +112,8 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { let sql = format!( "SELECT o.\"reportuid\", o.\"core_uid\", o.\"strategyid\", o.\"coin\", o.\"buydatems\", o.\"closedatems\", o.\"buyprice\", o.\"sellprice\", - o.\"spentbtc\", o.\"isshort\", o.\"sellreason\", COALESCE(o.pnl, 0), {deltas} + o.\"spentbtc\", o.\"isshort\", o.\"sellreason\", COALESCE(o.pnl, 0), {deltas}, + o.\"core_name\" FROM {src}" ); let mut stmt = conn.prepare(&sql).map_err(|e| read_fail_on(conn, CTX, e))?; @@ -172,6 +173,10 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { out.deals.push(Deal { report_uid, core_uid: int(1)? as u64, + core_name: r + .get::<_, Option>(12 + DELTA_COLS.len()) + .map_err(fail)? + .unwrap_or_default(), strategy_id, kind: String::new(), coin: r @@ -190,6 +195,8 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { profit: None, deltas, tick: None, + pre_spike_ask: None, + archived_take: None, }); order.push((close_ms, report_uid)); } diff --git a/crates/moon-core/src/db/tuner/ticks/entry.rs b/crates/moon-core/src/db/tuner/ticks/entry.rs index b4c4fe50..993eabdc 100644 --- a/crates/moon-core/src/db/tuner/ticks/entry.rs +++ b/crates/moon-core/src/db/tuner/ticks/entry.rs @@ -23,9 +23,9 @@ pub trait EntryModel { /// Args: /// deal: The report row; the model reads its deltas, side and price step. /// ticks: The window's prints, ascending by time. - /// start: `(t_ms, price)` the real order was first seen at, from the order archive, - /// when known — the model starts there instead of at the tape's first print. - fn fill(&self, deal: &Deal, ticks: &[Tick], start: Option<(i64, f64)>) -> Option; + /// line: The archived points of the real entry line, when known — the model starts + /// where the order stood at the tape's first print instead of placing off it. + fn fill(&self, deal: &Deal, ticks: &[Tick], line: Option<&[(i64, f64)]>) -> Option; } /// Whether the kind has an entry model at all — the UI's "Entry group available" test. @@ -37,7 +37,7 @@ pub fn entry_model_for(kind: &str) -> bool { } impl EntryModel for MshotEntry<'_> { - fn fill(&self, deal: &Deal, ticks: &[Tick], start: Option<(i64, f64)>) -> Option { - self.run(deal, ticks, start) + fn fill(&self, deal: &Deal, ticks: &[Tick], line: Option<&[(i64, f64)]>) -> Option { + self.run(deal, ticks, line) } } diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index be93914b..1e033680 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -4,13 +4,14 @@ //! //! The take-profit is `SellPrice` per cent above the fill, raised by `MShotSellAtLastPrice` to //! the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the 4-second-old ASK, i.e. before -//! the spike"; the model reads the last print at least [`PRE_SPIKE_LOOKBACK_MS`] before the -//! fill, since the tape has no book). From there the line moves under the strategy's sell rules +//! the spike"; the model takes the ask the caller recovered from the order archive +//! (`Deal::pre_spike_ask`), else reads the last print at least [`PRE_SPIKE_LOOKBACK_MS`] before +//! the fill, since the tape has no book). From there the line moves under the strategy's sell rules //! — `PriceDown*`, `SellLevel*`, `SellShot*` — and the stop fires under `StopLoss*`; see //! [`super::line`]. A position nothing closed inside the tape is [`ExitKind::OpenAtWindowEnd`]: //! not a trade, whatever the core's exit was. -use super::line::{LineWalk, walk}; +use super::line::{LineWalk, walk, walk_held}; use super::mshot::{DEFAULT_LATENCY_MS, PRE_SPIKE_LOOKBACK_MS}; use super::{Deal, Exit, Fill}; use crate::feed::types::Tick; @@ -60,6 +61,11 @@ pub struct ExitParams { pub stop_loss_delay_s: f64, /// Model parameter: how long a replacement of the sell takes to reach the book. pub latency_ms: f64, + /// Verdict-only: start the line at the archived take (`Deal::archived_take`) for a kind + /// whose take rule the model does not have. Off for every variant, whose take is the + /// `SellPrice` rule for every kind — so varying it moves every column the same way — and + /// on when the fact is replayed to be judged, where the core's own take is the truth. + pub take_from_archive: bool, } impl Default for ExitParams { @@ -97,6 +103,7 @@ impl Default for ExitParams { stop_loss_pct: 0.0, stop_loss_delay_s: 0.0, latency_ms: DEFAULT_LATENCY_MS, + take_from_archive: false, } } } @@ -112,8 +119,17 @@ impl<'a> ExitModel<'a> { } /// The take-profit level for a fill: `SellPrice` off the fill, lifted to the pre-spike - /// print less the adjustment when `MShotSellAtLastPrice` is on. Long above, short below. + /// ask (the archive's, else the tape's last print) less the adjustment when + /// `MShotSellAtLastPrice` is on. Long above, short below. pub fn take_level(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> f64 { + // A kind whose take rule the model does not have starts where the core's line did — + // when the fact is being judged; a variant computes the `SellPrice` take for every + // kind (see `ExitParams::take_from_archive`). + if self.params.take_from_archive && !take_model_for(&deal.kind) { + if let Some(take) = deal.archived_take.filter(|t| t.is_finite() && *t > 0.0) { + return take; + } + } let by_pct = fill.price * self.params.sell_price_pct / 100.0; let mut take = if deal.is_long() { fill.price + by_pct @@ -121,7 +137,11 @@ impl<'a> ExitModel<'a> { fill.price - by_pct }; if self.params.sell_at_last_price { - if let Some(pre) = pre_spike_price(ticks, fill.t_ms) { + let pre = deal + .pre_spike_ask + .filter(|p| p.is_finite() && *p > 0.0) + .or_else(|| pre_spike_price(ticks, fill.t_ms)); + if let Some(pre) = pre { let adjust = pre * self.params.sell_price_adjust_pct / 100.0; take = if deal.is_long() { take.max(pre - adjust) @@ -148,6 +168,69 @@ impl<'a> ExitModel<'a> { let take = self.take_level(deal, ticks, fill); walk(deal, ticks, fill, take, self.params) } + + /// The replay with the sell held until `hold_until_ms` — the line's levels through that + /// moment, whatever print would have sold it earlier (see [`walk_held`]). + pub fn walk_held( + &self, + deal: &Deal, + ticks: &[Tick], + fill: Fill, + hold_until_ms: i64, + ) -> LineWalk { + let take = self.take_level(deal, ticks, fill); + walk_held(deal, ticks, fill, take, self.params, Some(hold_until_ms)) + } +} + +/// Whether the model has the kind's own take rule — `SellPrice` lifted by +/// `MShotSellAtLastPrice` is MoonShot's; the other kinds place the take by rules of their own +/// that are not modelled, and the verdict takes it from the archive (`Deal::archived_take`, +/// `ExitParams::take_from_archive`). +pub fn take_model_for(kind: &str) -> bool { + super::entry::entry_model_for(kind) +} + +/// The take as an archived Exit line records it: its first point, when it is a price. +pub fn archived_take(exit_points: Option<&[(i64, f64)]>) -> Option { + let (_, take) = exit_points?.first().copied()?; + (take.is_finite() && take > 0.0).then_some(take) +} + +/// The pre-spike ask behind an archived Exit line: its first point is the take as the core +/// placed it, `ask · (1 − MShotSellPriceAdjust/100)` when `MShotSellAtLastPrice` lifted it — +/// `ask · (1 + adjust)` for a short, whose take sits below the entry and is adjusted UP toward +/// it — so the ask is that point with the trade's own adjustment divided out. `None` when the +/// rule was off (the take came from `SellPrice`, and the archive says nothing about the ask), +/// when the archive holds no Exit line, or when the first point is not a price. +/// +/// When `SellPrice` alone set the take higher than the ask would have, the division reads a +/// slightly high ask back — and the same `max` (a long) or `min` (a short, whose take sits +/// below the entry) puts the take on `SellPrice` again, so the trade's own replay is exact +/// either way; a variant with a smaller adjustment inherits the overread. +/// +/// Args: +/// exit_points: The archived Exit line's `(t_ms, price)` points, in the archive's order. +/// params: The sell-line parameters as of the trade. +/// is_short: The trade's side — which way the adjustment went. +pub fn archived_pre_spike_ask( + exit_points: Option<&[(i64, f64)]>, + params: &ExitParams, + is_short: bool, +) -> Option { + if !params.sell_at_last_price { + return None; + } + let factor = if is_short { + 1.0 + params.sell_price_adjust_pct / 100.0 + } else { + 1.0 - params.sell_price_adjust_pct / 100.0 + }; + if !(factor > 0.0) { + return None; + } + let (_, take) = exit_points?.first().copied()?; + (take.is_finite() && take > 0.0).then_some(take / factor) } /// The last print at least [`PRE_SPIKE_LOOKBACK_MS`] before `at_ms` — the FAQ's "price before diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs index 24546e4c..281f9aac 100644 --- a/crates/moon-core/src/db/tuner/ticks/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/line.rs @@ -31,10 +31,17 @@ //! the exchange `latency_ms` later, as the entry's does: a spike through the OLD level in that //! gap fills there. The line's replacements are recorded so the model can be held against the //! archived Exit line of the trade. +//! +//! The rules move an UNROUNDED line — the archive shows the core chaining its PriceDown steps +//! off the exact value, not the placed price — and only what goes to the exchange is rounded +//! to the nearest step of the market's price grid (a sell limit is placed on the grid). The +//! rounding is what decides a print AT the level: on ARX (2026-09-21) the +//! `PriceDownAllowedDrop` floor computed to 0.196445, the core's order stood at 0.1964, and +//! the tape's high was exactly 0.1964 — the unrounded line was never reached. use super::exit::ExitParams; use super::mshot::FAST_ALGO_WINDOW_MS; -use super::{Deal, Exit, ExitKind, Fill, reaches}; +use super::{Deal, Exit, ExitKind, Fill, reaches, round_to_step}; use crate::feed::types::Tick; /// The terminal's own floor on a step delay of zero: the FAQ's "0.33 s internal minimum". @@ -119,18 +126,45 @@ fn step_ms(seconds: f64) -> i64 { /// take: The take level the line starts at (see `ExitModel::take_level`). /// params: The sell-line rules. pub fn walk(deal: &Deal, ticks: &[Tick], fill: Fill, take: f64, params: &ExitParams) -> LineWalk { + walk_held(deal, ticks, fill, take, params, None) +} + +/// [`walk`] with the sell HELD — no print fills it — until `hold_until_ms`: the rules keep +/// moving the line, so its level at that moment is known whatever print the model would have +/// sold on before it. The stop is not held: it is a market order and fires as it does. The +/// verdict on a fact reads the line this way, at the close. +/// +/// Args: +/// hold_until_ms: `None` walks as [`walk`] does. +pub fn walk_held( + deal: &Deal, + ticks: &[Tick], + fill: Fill, + take: f64, + params: &ExitParams, + hold_until_ms: Option, +) -> LineWalk { let side = Side { long: deal.is_long(), }; let latency_ms = params.latency_ms.max(0.0) as i64; let armed_at = fill.t_ms + params.sell_delay_ms.max(0.0) as i64; + // What the exchange is given: the level on the price grid. + let placed = |level: f64| match deal.tick { + Some(tick) => round_to_step(level, tick), + None => level, + }; + let take_placed = placed(take); let mut points = vec![LinePoint { t_ms: armed_at, - price: take, + price: take_placed, }]; - // The exchange's level (what fills) and the core's (what the rules move); a move the - // exchange has not seen yet is `pending`. - let mut exch_line = take; + // The exchange's level (what fills, on the grid) and the core's (what the rules move, + // unrounded); a move the exchange has not seen yet is `pending`. + let mut exch_line = take_placed; + // Whether a move has reached the exchange: what tells a fill at the take from a fill at + // a level a rule moved the line to — not the price, which a moved line can round back onto. + let mut exch_moved = false; let mut core_line = take; let mut pending: Option<(i64, f64)> = None; let mut place = |t_ms: i64, level: f64, core: &mut f64, pending: &mut Option<(i64, f64)>| { @@ -138,6 +172,7 @@ pub fn walk(deal: &Deal, ticks: &[Tick], fill: Fill, take: f64, params: &ExitPar return; } *core = level; + let level = placed(level); *pending = Some((t_ms + latency_ms, level)); points.push(LinePoint { t_ms: t_ms + latency_ms, @@ -255,6 +290,7 @@ pub fn walk(deal: &Deal, ticks: &[Tick], fill: Fill, take: f64, params: &ExitPar } if let Some((_, level)) = pending.filter(|(apply_at, _)| t_ms >= *apply_at) { exch_line = level; + exch_moved = true; pending = None; } // The stop is a market order the core fires on the print; the sell is a limit the @@ -269,13 +305,19 @@ pub fn walk(deal: &Deal, ticks: &[Tick], fill: Fill, take: f64, params: &ExitPar points, }; } - if t_ms > armed_at && reaches(price, exch_line, !side.long) { + // A print AT the level fills the sell — the optimistic reading the spec states (§7: + // the queue standing at the level is not modelled; COOL 2026-09-21 printed 31 + // contracts at the level against a sell of 18 000 and the core's line stood). The + // verdict on the fact does not lean on this: `verify` judges the line by where it + // STOOD at the close, not by which print the model sold on. + let held = hold_until_ms.is_some_and(|until| t_ms <= until); + if t_ms > armed_at && !held && reaches(price, exch_line, !side.long) { return LineWalk { exit: Exit { t_ms, price: exch_line, // What the print met: the take as placed, or a level a rule moved it to. - kind: if exch_line == take { + kind: if !exch_moved { ExitKind::Take } else { ExitKind::Line diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index 55caf4e4..bbac45dc 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -1,7 +1,7 @@ //! The sell line's rules on synthetic tapes: one rule at a time, then the mirror. use super::*; -use crate::db::tuner::ticks::exit::ExitModel; +use crate::db::tuner::ticks::exit::{ExitModel, archived_pre_spike_ask}; use crate::db::tuner::ticks::{Deltas, EntryParams, verify}; use crate::feed::types::Side as TickSide; @@ -22,6 +22,7 @@ fn deal(short: bool) -> Deal { Deal { report_uid: 1, core_uid: 7, + core_name: String::new(), strategy_id: 42, kind: "MoonShot".into(), coin: "ACE".into(), @@ -36,6 +37,8 @@ fn deal(short: bool) -> Deal { profit: None, deltas: Deltas::default(), tick: None, + pre_spike_ask: None, + archived_take: None, } } @@ -54,6 +57,52 @@ fn params() -> ExitParams { } } +// ---- the take off the archive ---------------------------------------------------------------- + +/// `MShotSellAtLastPrice` reads the book's ask, which the tape has not; the archive's first +/// Exit point gives it back with the trade's own adjustment divided out, and a caller that +/// recovered it gets a take the tape alone would have put 0.5 % lower. +#[test] +fn the_archived_ask_sets_the_take_where_the_core_placed_it() { + let p = ExitParams { + sell_price_pct: 1.5, + sell_at_last_price: true, + sell_price_adjust_pct: 0.2, + ..params() + }; + // GSTOCKBSC, 2026-09-21: the take as placed, 0.031603, is the ask 0.0316663 less 0.2 %. + let ask = archived_pre_spike_ask(Some(&[(0, 0.031603), (1_266, 0.030910)]), &p, false) + .expect("the rule was on"); + assert!((ask - 0.031603 / 0.998).abs() < 1e-12); + let mut d = deal(false); + d.buy_price = 0.029292; + d.pre_spike_ask = Some(ask); + let f = Fill { + t_ms: 0, + price: 0.029292, + }; + // The tape's own pre-spike print sits 0.5 % under the ask. + let ticks = tape(&[(-5_000, 0.031506), (1_000, 0.0300)]); + let take = ExitModel::new(&p).take_level(&d, &ticks, f); + assert!((take - 0.031603).abs() < 1e-9, "{take}"); + d.pre_spike_ask = None; + let from_tape = ExitModel::new(&p).take_level(&d, &ticks, f); + assert!((from_tape - 0.031506 * 0.998).abs() < 1e-7, "{from_tape}"); + // With the rule off the archive says nothing about the ask. + let off = ExitParams { + sell_at_last_price: false, + ..p.clone() + }; + assert_eq!( + archived_pre_spike_ask(Some(&[(0, 0.031603)]), &off, false), + None + ); + assert_eq!(archived_pre_spike_ask(None, &p, false), None); + // A short's take is the ask adjusted UP toward the entry: divide the other way. + let short_ask = archived_pre_spike_ask(Some(&[(0, 0.031603)]), &p, true).expect("on"); + assert!((short_ask - 0.031603 / 1.002).abs() < 1e-12); +} + // ---- PriceDown ------------------------------------------------------------------------------- #[test] @@ -89,6 +138,77 @@ fn price_down_steps_the_line_toward_the_buy_on_the_timer() { assert!((w.exit.price - 100.1).abs() < 1e-9); } +#[test] +fn the_placed_level_is_rounded_to_the_step_and_the_chain_is_not() { + // ARX, 2026-09-21, step 0.0001: take 0.197802 goes to the book as 0.1978; the 20 % + // relative steps chain off the exact values (0.19714 → 0.196612, not off 0.1971), and the + // floor at +1 % (0.196445) is placed at 0.1964 — which is why the print AT 0.1964 sells. + let p = ExitParams { + price_down_timer_s: 3.0, + price_down_pct: 20.0, + price_down_delay_s: 10.0, + price_down_relative: true, + price_down_allowed_drop_pct: 1.0, + ..params() + }; + let mut d = deal(false); + d.buy_price = 0.1945; + d.tick = Some(0.0001); + let fill = Fill { + t_ms: 0, + price: 0.1945, + }; + let ticks = tape(&[ + (1_000, 0.1950), + (4_000, 0.1950), + (14_000, 0.1950), + (24_000, 0.1950), + (34_000, 0.1950), + (40_000, 0.1964), + ]); + let w = walk(&d, &ticks, fill, 0.197802, &p); + let levels: Vec = w.points.iter().map(|pt| pt.price).collect(); + let on_grid = |v: f64| (v / 0.0001).round() * 0.0001; + assert_eq!(levels.len(), 4, "{levels:?}"); + for (level, want) in levels.iter().zip([0.1978, 0.1971, 0.1966, 0.1964]) { + assert!((level - want).abs() < 1e-9, "{levels:?}"); + assert!( + (level - on_grid(*level)).abs() < 1e-9, + "off the grid: {level}" + ); + } + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 40_000)); + assert!( + (w.exit.price - 0.1964).abs() < 1e-9, + "sold at the placed level" + ); +} + +#[test] +fn without_a_step_the_placed_level_is_the_exact_one() { + let p = ExitParams { + price_down_timer_s: 3.0, + price_down_pct: 20.0, + price_down_delay_s: 10.0, + price_down_relative: true, + price_down_allowed_drop_pct: 1.0, + ..params() + }; + let mut d = deal(false); + d.buy_price = 0.1945; + let fill = Fill { + t_ms: 0, + price: 0.1945, + }; + let ticks = tape(&[(1_000, 0.1950), (40_000, 0.1964)]); + let w = walk(&d, &ticks, fill, 0.197802, &p); + assert_eq!( + w.exit.kind, + ExitKind::OpenAtWindowEnd, + "0.196445 is above the tape's high" + ); +} + #[test] fn price_down_absolute_takes_a_share_of_the_price() { // SellPrice 1 %, pct 0.2 absolute: 101 -> 100.8 (of the buy price). diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 9582f36e..48763984 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -38,7 +38,7 @@ pub mod verify; pub use deals::{DealsRead, read_deals}; pub use entry::{EntryModel, entry_model_for}; -pub use exit::{ExitModel, ExitParams}; +pub use exit::{ExitModel, ExitParams, archived_pre_spike_ask, archived_take, take_model_for}; pub use mshot::{MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; pub use scope::{is_service_row, is_tunable}; @@ -73,6 +73,43 @@ pub fn reaches(price: f64, level: f64, from_below: bool) -> bool { } } +/// A level snapped to the market's price grid: down to the step below when `down`, up to the +/// step above otherwise — the entry's placement, which the core rounds AWAY from the price. A +/// level already on the grid stays — the quotient is read with [`PRICE_EPS`] of slack, so +/// `0.3379` computed as `0.33789999` does not lose a step. A non-positive step snaps nothing. +/// +/// Args: +/// level: The price to snap. +/// tick: The price step. +/// down: Whether to round toward zero (a long's buy sits below the price) or away from it +/// (a short's sits above). +pub fn snap_to_step(level: f64, tick: f64, down: bool) -> f64 { + if tick <= 0.0 || tick.is_nan() || !level.is_finite() { + return level; + } + let steps = level / tick; + let snapped = if down { + (steps + PRICE_EPS).floor() + } else { + (steps - PRICE_EPS).ceil() + }; + snapped * tick +} + +/// A level rounded to the NEAREST step of the price grid — the sell line's placement: the +/// archived Exit lines round both ways (2026-09-21, 458 deals: a floor lost 5 exits the +/// nearest step keeps, ARX's four levels agree with both). A non-positive step rounds nothing. +/// +/// Args: +/// level: The price to round. +/// tick: The price step. +pub fn round_to_step(level: f64, tick: f64) -> f64 { + if tick <= 0.0 || tick.is_nan() || !level.is_finite() { + return level; + } + (level / tick).round() * tick +} + /// The report-side deltas the MoonShot modifiers read, as of the BUY of the trade. /// /// The report stamps them once, at the buy; the model treats them as constant over the window, @@ -107,6 +144,9 @@ pub struct Deal { /// `reportuid` — the key of the order-trace archive and of the coverage map. pub report_uid: i64, pub core_uid: u64, + /// The core's name as the report row carries it (`core_name`) — the table's core column; + /// the uid is the key, the name is what the user knows the core by. + pub core_name: String, pub strategy_id: i64, /// Strategy kind as the strategy list names it (`"MoonShot"`, `"Spread"`, …); selects the /// entry model through [`entry_model_for`]. @@ -142,6 +182,21 @@ pub struct Deal { /// Price step of the market, when the caller could resolve it (the live catalog, or /// [`infer_tick`] over the window). `None` disables the step-bound rules and rounds nothing. pub tick: Option, + /// The book's ASK the core read `MShotSellAtLastPrice` off — "the 4-second-old ASK, before + /// the spike" — when the caller could recover it: the archived Exit line's first point is + /// the take as placed, and dividing out the trade's own `MShotSellPriceAdjust` gives the + /// ask back. `None` leaves the model to its own reading of the tape (the last print at + /// least [`mshot::PRE_SPIKE_LOOKBACK_MS`] before the fill), which sits below the ask on a + /// dump by 0.1–0.5 % (B2/CELR 2026-09-20, GSTOCKBSC 2026-09-21) and shifts every level + /// the sell line then steps down from. + pub pre_spike_ask: Option, + /// The take as the core placed it — the archived Exit line's first point — when the + /// archive holds it. The one take rule the model has is MoonShot's (`SellPrice` lifted by + /// `MShotSellAtLastPrice`); every other kind places its take by a rule of its own + /// (MoonHook's buffer, Spread's level), and for those the archive is where the line + /// starts. FLOCK 2026-09-21 (HookN0, short): the model's `SellPrice` take at −1.0 %, the + /// core's at −2.2 %, every PriceDown step then a different level. + pub archived_take: Option, } impl Deal { @@ -263,22 +318,23 @@ pub fn required_spans(deal: &Deal, spans: &Coverage) -> Coverage { /// ticks: Prints of the window, ascending. /// entry: Entry model parameters, or the fact. /// exit: Sell-line parameters. -/// entry_start: The `(t_ms, price)` the real entry line was first seen at, when the order -/// archive holds it. The model then starts its order there rather than at the window's -/// first print, which is the one thing about the order's history the tape cannot tell. +/// entry_line: The archived points of the real entry line, when the order archive holds +/// it. The model then starts its order where the archive says it stood when the tape +/// begins, rather than at the window's first print — the one thing about the order's +/// history the tape cannot tell (see [`mshot`] for what else the line gives). pub fn simulate( deal: &Deal, ticks: &[Tick], entry: &EntryParams, exit: &ExitParams, - entry_start: Option<(i64, f64)>, + entry_line: Option<&[(i64, f64)]>, ) -> Outcome { let fill = match entry { EntryParams::Fact => Some(Fill { t_ms: deal.buy_ms, price: deal.buy_price, }), - EntryParams::MoonShot(params) => MshotEntry::new(params).fill(deal, ticks, entry_start), + EntryParams::MoonShot(params) => MshotEntry::new(params).fill(deal, ticks, entry_line), }; let Some(fill) = fill else { return Outcome { diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index 598df9bc..79603127 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -30,8 +30,22 @@ //! The core keeps its own idea of where the order is; the exchange learns about a move //! `latency_ms` later. A print in between fills at the OLD level. That is the one interleaving //! the model has to get right, and it is why the state carries two levels. +//! +//! What the order archive adds, when it holds the trade's entry line: where the order stood +//! when the tape begins (the last archived level at or before the first print — the tape +//! cannot tell where an order placed minutes earlier was), and the core's first moves inside +//! the model's BLIND WINDOW — the first `MShotRaiseWait` / `MShotReplaceDelay` seconds of the +//! tape, where a wait the core started before the tape began expires at a moment the tape +//! gives no way to compute. A move archived in that window is taken as archived, moment and +//! level both: the level is the core's own reference at work, and on a coarse grid the model's +//! reference off the prints lands a step away often enough to turn the fill into a miss +//! (2026-09-21, 458 deals: 4 entries lost to a modelled level, none gained by it). Seen on +//! ARX the same day: the core re-placed 0.3 s into the tape after a wait of 30 s, and the model +//! waiting its own 30 s put the order one step too deep for the spike. Past the window the +//! model is on its own, and the archive is what it is held against. -use super::{Deal, Deltas, Fill, reaches}; +use super::verify::archived_replacements; +use super::{Deal, Deltas, Fill, reaches, snap_to_step}; use crate::feed::types::{Side, Tick}; /// Which price the order keeps its distance from (`MShotUsePrice`). @@ -202,12 +216,7 @@ impl<'a> MshotEntry<'a> { level.max(reference + keep_off) }; } - let steps = level / tick; - level = if deal.is_long() { - steps.floor() * tick - } else { - steps.ceil() * tick - }; + level = snap_to_step(level, tick, deal.is_long()); } level } @@ -227,12 +236,19 @@ impl<'a> MshotEntry<'a> { signed / reference * 100.0 } - /// The tape replay — see the module doc for the two-level bookkeeping. + /// The tape replay — see the module doc for the two-level bookkeeping and for what the + /// archived entry line contributes. + /// + /// Args: + /// deal: The report row. + /// ticks: The window's prints, ascending. + /// line: The archived points of the trade's own entry line, in the archive's order, + /// when the archive holds it. pub(super) fn run( &self, deal: &Deal, ticks: &[Tick], - start: Option<(i64, f64)>, + line: Option<&[(i64, f64)]>, ) -> Option { if ticks.is_empty() { return None; @@ -248,8 +264,42 @@ impl<'a> MshotEntry<'a> { deal.is_long(), ); - // Where the tape starts for the order: at the archive's first point, or at the first - // print. Prints before the start only feed the reference. + // The archive's moves, and where the order stood when the tape begins: the last + // archived level at or before the first print, else the archive's first point (an + // order placed inside the tape starts at its own moment), else nothing. + let first_print_ms = ticks[0].time_ms as i64; + let moves: Vec<(i64, f64)> = line + .filter(|l| !l.is_empty()) + .map(archived_replacements) + .unwrap_or_default(); + let start: Option<(i64, f64)> = moves + .iter() + .filter(|(t, _)| *t <= first_print_ms) + .max_by_key(|(t, _)| *t) + .or(moves.first()) + .copied(); + // The blind window: the core's moves archived inside it are applied as archived, + // because the wait behind each began before the tape did. Only moves after the start + // and before the fill count. + let blind_until_ms = first_print_ms + raise_wait_ms.max(replace_delay_ms) as i64; + let mut hints: Vec<(i64, f64)> = moves + .iter() + .filter(|(t, p)| { + start.is_none_or(|(s, _)| *t > s) + && *t > first_print_ms + && *t < blind_until_ms + && *t < deal.buy_ms + && *p > 0.0 + }) + .copied() + .collect(); + // The archive files a move as the old level's end and the new one's start, a few + // milliseconds apart and not always in that order; the hints are walked in time. + hints.sort_by_key(|(t, _)| *t); + let mut hints = hints.into_iter().peekable(); + + // Where the tape starts for the order: at the archived start, or at the first print. + // Prints before the start only feed the reference. let start_ms = start.map(|(t, _)| t); let mut index = 0; if let Some(start_ms) = start_ms { @@ -281,6 +331,13 @@ impl<'a> MshotEntry<'a> { if !price.is_finite() || price <= 0.0 { continue; } + // An archived move due by this print happened before it; the exchange learns of + // it after the latency, like any move. + while let Some((hint_ms, level)) = hints.next_if(|(h, _)| *h <= t_ms) { + core_level = level; + pending = Some((hint_ms + latency_ms as i64, level)); + breach = None; + } if let Some((_, level)) = pending.filter(|(apply_at, _)| t_ms >= *apply_at) { exch_level = level; pending = None; diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index ba62fb0b..0faecbfd 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -422,5 +422,6 @@ pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { stop_loss_pct: v.num("StopLoss", base.stop_loss_pct), stop_loss_delay_s: v.num("StopLossDelay", base.stop_loss_delay_s), latency_ms: base.latency_ms, + take_from_archive: base.take_from_archive, } } diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index f34a574a..a5d8c1b2 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -40,8 +40,9 @@ pub struct PreparedDeal { pub deal: Deal, /// The window's prints, ascending; shared, never copied per evaluation. pub ticks: Arc<[Tick]>, - /// The archived first point of the entry line, when the archive holds it. - pub entry_start: Option<(i64, f64)>, + /// The archived points of the entry line, when the archive holds it; shared like the + /// tape. + pub entry_line: Option>, /// How far past the close the HELD COVERAGE of this deal's window reaches, in /// milliseconds — the caller's word from the tile store, not the last print's stamp: a /// quiet market prints nothing for seconds, and a tail measured by its last print would @@ -196,7 +197,7 @@ fn tally_and_spent(deals: &[PreparedDeal], entry: &EntryParams, exit: &ExitParam let results: Vec> = deals .par_iter() .map(|d| { - simulate(&d.deal, &d.ticks, entry, exit, d.entry_start) + simulate(&d.deal, &d.ticks, entry, exit, d.entry_line.as_deref()) .profit_money(&d.deal) .map(|money| (money, d.deal.spent)) }) diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 06ee766e..84b5d6c2 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -22,6 +22,7 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { let deal = Deal { report_uid: uid, core_uid: 1, + core_name: String::new(), strategy_id: 1, kind: "Spread".into(), coin: "ACE".into(), @@ -36,6 +37,8 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { profit: None, deltas: Deltas::default(), tick: None, + pre_spike_ask: None, + archived_take: None, }; let t0 = deal.buy_ms; let ticks: Vec = vec![ @@ -48,7 +51,7 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { PreparedDeal { deal, ticks: Arc::from(ticks), - entry_start: None, + entry_line: None, trail_ms: 0, } } diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index 64a42c97..0b000753 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -5,6 +5,7 @@ fn deal(pnl: f64, spent: f64) -> Deal { Deal { report_uid: 1, core_uid: 7, + core_name: String::new(), strategy_id: 42, kind: "MoonShot".into(), coin: "ACE".into(), @@ -19,6 +20,8 @@ fn deal(pnl: f64, spent: f64) -> Deal { profit: None, deltas: Deltas::default(), tick: None, + pre_spike_ask: None, + archived_take: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 7ee3a235..3194d635 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -31,6 +31,7 @@ fn deal() -> Deal { Deal { report_uid: 1, core_uid: 7, + core_name: String::new(), strategy_id: 42, kind: "MoonShot".into(), coin: "ACE".into(), @@ -45,6 +46,8 @@ fn deal() -> Deal { profit: None, deltas: Deltas::default(), tick: None, + pre_spike_ask: None, + archived_take: None, } } @@ -103,12 +106,101 @@ fn the_archived_start_places_the_order_where_the_core_did() { // The tape says 100 at t=0, but the archive says the order stood at 98.5 from t=200. let ticks = tape(&[(0, 100.0), (300, 99.0), (600, 98.5)]); let fill = MshotEntry::new(&mshot()) - .fill(&deal(), &ticks, Some((200, 98.5))) + .fill(&deal(), &ticks, Some(&[(200, 98.5)])) .expect("filled"); assert_eq!(fill.t_ms, 600); assert!((fill.price - 98.5).abs() < 1e-9); } +#[test] +fn the_archived_level_at_the_tape_start_is_the_last_one_before_it() { + // The archive: 97 from t=-500, moved to 98.5 at t=-100; the tape begins at t=0. The order + // stands at 98.5, not at the archive's first level: the prints at 99.2 and 99 leave it + // inside the 1 % / 0.5 % corridor, and the print at 98.5 fills it. Started at 97 it would + // be 2.2 % off the first print, re-placed at once to 98.2, and never reached. + let ticks = tape(&[(0, 99.2), (300, 99.0), (600, 98.5)]); + let fill = MshotEntry::new(&mshot()) + .fill(&deal(), &ticks, Some(&[(-500, 97.0), (-100, 98.5)])) + .expect("filled"); + assert_eq!(fill.t_ms, 600); + assert!((fill.price - 98.5).abs() < 1e-9); + assert_eq!( + MshotEntry::new(&mshot()).fill(&deal(), &ticks, Some(&[(-500, 97.0)])), + None, + "from the first level the order is re-placed below the tape" + ); +} + +#[test] +fn the_archived_level_is_the_latest_by_time_whatever_the_archive_order() { + // The archive files a move as two points a few milliseconds apart, not always in time + // order: here the 98.5 level's point precedes the 97 level's in the slice while following + // it in time. The start is the latest by time, 98.5. + let ticks = tape(&[(0, 99.2), (300, 99.0), (600, 98.5)]); + let fill = MshotEntry::new(&mshot()) + .fill(&deal(), &ticks, Some(&[(-100, 98.5), (-131, 97.0)])) + .expect("filled"); + assert_eq!(fill.t_ms, 600); + assert!((fill.price - 98.5).abs() < 1e-9); +} + +#[test] +fn a_move_archived_inside_the_blind_window_is_applied_as_archived() { + // Raise wait 30 s. The archive: 97 from t=-60 000 (the price ran away long before the + // tape), re-placed at 99 at t=300 — 0.3 s into the tape, a wait the model cannot see the + // start of. The print at 99 at t=1000 fills the archived level; on its own the model would + // wait 30 s from the first print and never fill (the tape ends first). + let params = MshotParams { + raise_wait_s: 30.0, + ..mshot() + }; + let ticks = tape(&[(0, 100.0), (500, 100.0), (1_000, 99.0), (2_000, 100.0)]); + let line = [(-60_000, 97.0), (300, 99.0)]; + let fill = MshotEntry::new(¶ms) + .fill(&deal(), &ticks, Some(&line)) + .expect("filled at the archived move"); + assert_eq!(fill.t_ms, 1_000); + assert!((fill.price - 99.0).abs() < 1e-9); + assert_eq!( + MshotEntry::new(¶ms).fill(&deal(), &ticks, Some(&line[..1])), + None, + "without the move the model waits its 30 s" + ); +} + +#[test] +fn a_move_archived_past_the_blind_window_is_not_applied() { + // The same, but the archived move sits at t=31 000 — past the 30 s the model can account + // for on its own; it is left to the model, which by then has re-placed by its own rule + // (Retreat since the first print, at t=30 000: off the reference 100, level 99, effective + // at 30 100) — the print at 98 at t=31 500 fills THAT level, not the archived 97.5. + let params = MshotParams { + raise_wait_s: 30.0, + ..mshot() + }; + let ticks = tape(&[(0, 100.0), (30_000, 100.0), (31_500, 98.0)]); + let line = [(-60_000, 96.0), (31_000, 97.5)]; + let fill = MshotEntry::new(¶ms) + .fill(&deal(), &ticks, Some(&line)) + .expect("filled"); + assert!((fill.price - 99.0).abs() < 1e-9, "{fill:?}"); +} + +#[test] +fn snap_to_step_keeps_a_level_already_on_the_grid() { + // 0.3379 / 0.0001 evaluates to 3378.9999999999995: a plain floor loses the step. + assert!((snap_to_step(0.3379, 0.0001, true) - 0.3379).abs() < 1e-12); + assert!((snap_to_step(0.33795, 0.0001, true) - 0.3379).abs() < 1e-12); + assert!((snap_to_step(0.33795, 0.0001, false) - 0.3380).abs() < 1e-12); + assert_eq!( + snap_to_step(0.33795, 0.0, true), + 0.33795, + "no step, no snap" + ); + assert!((round_to_step(0.196445, 0.0001) - 0.1964).abs() < 1e-12); + assert!((round_to_step(0.19646, 0.0001) - 0.1965).abs() < 1e-12); +} + // ---- entry: the corridor, the waits and the latency race ---------------------------------- #[test] @@ -642,7 +734,9 @@ fn verify_marks_a_missed_entry_and_judges_the_exit_from_the_fact() { // take off the fact's 99.0 is reached at t=20000. assert_eq!(v.exit, Some(true)); - // Fact entry, take never reached: the exit is the fact and answers nothing. + // Fact entry, and no print reaches the take: the line still STOOD at 99.99 when the core + // sold at 100.0 — which print would have filled it is the queue's business, not the + // verdict's — so the exit is reproduced. let ticks = tape(&[ (9_000, 100.0), (10_000, 99.0), @@ -658,13 +752,21 @@ fn verify_marks_a_missed_entry_and_judges_the_exit_from_the_fact() { None, ); assert_eq!(v.entry, None); - // The core did close it and the model never did: the exit group missed. + assert_eq!(v.exit, Some(true)); + assert_eq!(v.exit_kind, Some(ExitKind::Take)); + // A sell delay that outlives the trade leaves no line at the close: a miss. + let late = ExitParams { + sell_delay_ms: 30_000.0, + ..ExitParams::default() + }; + let v = verify(&deal(), &ticks, &EntryParams::Fact, &late, None, None); assert_eq!(v.exit, Some(false)); assert_eq!(v.exit_kind, Some(ExitKind::OpenAtWindowEnd)); } #[test] fn verify_reports_the_deviation_of_an_entry_off_the_fact() { + // The 1 % / 0.5 % corridor is 0.5 % wide: a fill 1.02 % off the fact is another order. let mut d = deal(); d.buy_price = 98.0; let ticks = tape(&[(0, 100.0), (10_000, 99.0)]); @@ -681,6 +783,41 @@ fn verify_reports_the_deviation_of_an_entry_off_the_fact() { assert!((dev - 99.0 / 98.0 * 100.0 + 100.0).abs() < 1e-6, "{dev}"); } +#[test] +fn verify_holds_the_entry_to_the_corridors_width() { + // The same 0.5 %-wide corridor: a fill 0.3 % off the fact is the same order re-placed off + // a neighbouring print, and passes; the floor is the 0.05 % step for a corridor narrower + // than it. + let mut d = deal(); + d.buy_price = 99.0 / 1.003; + let ticks = tape(&[(0, 100.0), (10_000, 99.0), (20_000, 100.0)]); + let v = verify( + &d, + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + None, + ); + assert_eq!(v.entry, Some(true), "{:?}", v.entry_dev_pct); + assert!((verify::entry_tolerance_pct(&mshot(), &d) - 0.5).abs() < 1e-9); + let narrow = MshotParams { + price_pct: 1.0, + price_min_pct: 0.99, + ..mshot() + }; + assert!((verify::entry_tolerance_pct(&narrow, &d) - 0.05).abs() < 1e-9); + let v = verify( + &d, + &ticks, + &EntryParams::MoonShot(narrow), + &ExitParams::default(), + None, + None, + ); + assert_eq!(v.entry, Some(false), "0.3 % off on a 0.01 % corridor"); +} + #[test] fn verify_leaves_a_take_unanswered_against_a_fact_another_rule_closed() { // The tape reaches the take, but the core closed by Auto Price Down: not the same rule, @@ -701,6 +838,86 @@ fn verify_leaves_a_take_unanswered_against_a_fact_another_rule_closed() { assert_eq!(v.exit_dev_pct, None); } +#[test] +fn verify_takes_a_limits_better_fill_and_ignores_the_archived_fill_point() { + // Fact: buy 99.0, a 1 % take at 99.99 placed and never moved, sold at 100.2 — a gap fill + // 0.21 % ABOVE the limit. The archive files the take and then the fill itself at the + // close; the fill is not a move the model has to make. + let mut d = deal(); + d.sell_price = 100.2; + d.close_ms = 20_000; + let ticks = tape(&[(0, 100.0), (10_000, 99.0), (20_000, 100.2)]); + let archived = [ + (10_000, 99.99), + (20_000, 99.99), + (19_990, 100.2), + (20_000, 100.2), + ]; + let v = verify( + &d, + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + Some(&archived), + ); + assert_eq!(v.exit_kind, Some(ExitKind::Take)); + assert_eq!(v.exit, Some(true), "{v:?}"); + assert_eq!(v.line_points, Some((1, 1)), "the fill point is not a move"); + // The same better fill without the archive to say the line was the same line: not taken. + let v = verify( + &d, + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + None, + ); + assert_eq!( + v.exit, + Some(false), + "a better fill needs the archive behind it" + ); + // A fact far beyond the level is another exit, not a better fill of this one — with the + // archive corroborating the line, so it is the bound that refuses it. + d.sell_price = 100.5; + let ticks = tape(&[(0, 100.0), (10_000, 99.0), (20_000, 100.5)]); + let archived = [ + (10_000, 99.99), + (20_000, 99.99), + (19_990, 100.5), + (20_000, 100.5), + ]; + let v = verify( + &d, + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + Some(&archived), + ); + assert_eq!(v.line_points, Some((1, 1))); + assert_eq!(v.exit, Some(false), "0.51 % beyond the level, {v:?}"); + // Worse than the level is never a fill of it, archive or not — the sign, not the bound. + d.sell_price = 99.9; + let archived = [ + (10_000, 99.99), + (20_000, 99.99), + (19_990, 99.9), + (20_000, 99.9), + ]; + let v = verify( + &d, + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + Some(&archived), + ); + assert_eq!(v.line_points, Some((1, 1))); + assert_eq!(v.exit, Some(false), "{v:?}"); +} + #[test] fn share_counts_only_answered_verdicts() { assert_eq!(share([Some(true), None, Some(false), Some(true)]), (2, 3)); diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index e6b592b2..d3480ec8 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -7,8 +7,8 @@ //! deal's prints through the worker's held-data query (`query_held`, the path the table's //! coverage column uses) and its entry line from `order_traces.sqlite`, runs [`verify`] on the //! parameters as of the buy, and prints one line per deal plus the ✓ share per group. The -//! environment variable is read HERE only, in a test a developer runs by hand; the application -//! never moves its data root on a variable. +//! environment variables are read HERE only, in a test a developer runs by hand (`MOON_TICKS_COIN` +//! narrows the run to one coin); the application never moves its data root on a variable. use std::collections::HashMap; use std::path::PathBuf; @@ -29,11 +29,11 @@ use crate::feed::report_traces::ArchivedLineKind; use crate::market::trade_replay::{Coverage, TickQuery, query_held, replay_window_ms}; use crate::symbol::{coin_match_key, coin_of_market}; -/// The archived first point of an entry line and every point of an exit line. -type ArchivedLines = (Option<(i64, f64)>, Option>); +/// Every point of an archived entry line and of an exit line. +type ArchivedLines = (Option>, Option>); -/// The archived first point of the deal's own entry line and every point of its own exit -/// line, when the archive holds them. +/// The points of the deal's own entry line and of its own exit line, when the archive holds +/// them. fn archived_lines(deal: &Deal) -> ArchivedLines { let Ok(entries) = read_many(deal.core_uid, &[deal.report_uid]) else { return (None, None); @@ -43,7 +43,7 @@ fn archived_lines(deal: &Deal) -> ArchivedLines { let entry = lines .iter() .find(|l| l.own && l.kind == ArchivedLineKind::Entry) - .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))); + .map(|l| l.points.iter().map(|&(t, p)| (t as i64, p)).collect()); let exit = lines .iter() .find(|l| l.own && l.kind == ArchivedLineKind::Exit) @@ -124,9 +124,17 @@ fn real_data_reproduction() { let mut kinds_seen: HashMap = HashMap::new(); for mut deal in read.deals { *kinds_seen.entry(deal.kind.clone()).or_default() += 1; - if !entry_model_for(&deal.kind) { + // Every kind the axis takes, as the table does: a kind without an entry model replays + // its exit from the factual entry. + if !is_tunable(&deal.kind, &deal.sell_reason) { continue; } + // One coin, when a single deal is under the glass: `MOON_TICKS_COIN=ARX`. + if let Ok(only) = std::env::var("MOON_TICKS_COIN") { + if deal.coin != only { + continue; + } + } let Some(values) = strategy_values_at(deal.strategy_id, Some(deal.core_uid), deal.buy_ms, &keys) else { @@ -158,16 +166,22 @@ fn real_data_reproduction() { } with_tape += 1; deal.tick = infer_tick(&ticks); - let (entry_start, exit_points) = archived_lines(&deal); + let (entry_line, exit_points) = archived_lines(&deal); let sv = StrategyValues { values: &values, defaults: &defaults, }; - let entry = EntryParams::MoonShot(mshot_params(&sv, DEFAULT_LATENCY_MS)); + let entry = if entry_model_for(&deal.kind) { + EntryParams::MoonShot(mshot_params(&sv, DEFAULT_LATENCY_MS)) + } else { + EntryParams::Fact + }; let exit = exit_params(&sv); + deal.pre_spike_ask = archived_pre_spike_ask(exit_points.as_deref(), &exit, deal.is_short); + deal.archived_take = archived_take(exit_points.as_deref()); // The modelled line beside the archive's moves, for the eye. if let (Some(fill), Some(moves)) = ( - simulate(&deal, &ticks, &entry, &exit, entry_start).fill, + simulate(&deal, &ticks, &entry, &exit, entry_line.as_deref()).fill, exit_points.as_deref().map(verify::archived_replacements), ) { let modelled = ExitModel::new(&exit).walk(&deal, &ticks, fill); @@ -188,20 +202,21 @@ fn real_data_reproduction() { &ticks, &entry, &exit, - entry_start, + entry_line.as_deref(), exit_points.as_deref(), ); eprintln!( - "{uid} {coin:<8} buy {buy:.6} | plain fill {fill:?} dev {dev:?} ✓{ok:?} | \ + "{uid} {coin:<8} {kind:<8} buy {buy:.6} | plain fill {fill:?} dev {dev:?} ✓{ok:?} | \ archived start {start:?} fill {fill2:?} dev {dev2:?} ✓{ok2:?} | \ exit {exit_kind:?} ✓{exit_ok:?} dev {exit_dev:?} line {line:?} | {reason} | ticks {n} step {tick:?}", uid = deal.report_uid, coin = deal.coin, + kind = deal.kind, buy = deal.buy_price, fill = plain.fill.map(|f| f.price), dev = round3(plain.entry_dev_pct), ok = plain.entry, - start = entry_start, + start = entry_line.as_ref().and_then(|l| l.first()), fill2 = archived.fill.map(|f| f.price), dev2 = round3(archived.entry_dev_pct), ok2 = archived.entry, @@ -213,7 +228,7 @@ fn real_data_reproduction() { n = ticks.len(), tick = deal.tick, ); - let best = if entry_start.is_some() { + let best = if entry_line.is_some() { archived } else { plain diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 2d81bdf1..298abae1 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -10,15 +10,28 @@ //! it is not modelled; an exit the core closed by a rule the model does not have is not a miss //! of the rules it does. An exit the model never reached at all IS a miss. //! -//! The exit is walked from the FACTUAL entry and held against two things: the price the core -//! sold at, and — when the order archive holds the trade's Exit line — every move the core -//! made with its sell, each of which the model must have made too within -//! [`POINT_TIME_TOLERANCE_MS`] and [`PRICE_TOLERANCE`]. A model that lands on the right price -//! by a different path has not reproduced the rule. +//! The entry is held to the CORRIDOR, not to a price step: a MoonShot order chasing a falling +//! price is re-placed off whichever print left the corridor, and the core's print and the +//! model's differ by a second and a fraction of a per cent on every such chase (GSTOCKBSC +//! 2026-09-21: the core off 0.031130 at −0.55 s, the model off 0.031253 at −2.1 s, levels +//! 0.39 % apart on a 1 % corridor, both filled by the same dump). A fill within the corridor's +//! own width of the fact is the same order in the same corridor; the 0.05 % step is the floor +//! for a corridor narrower than that. +//! +//! The exit is walked from the FACTUAL entry and held against two things: where the +//! modelled line STOOD at the moment the core sold — against the price it sold at — and, when +//! the order archive holds the trade's Exit line, every move the core made with its sell, +//! each of which the model must have made too within [`POINT_TIME_TOLERANCE_MS`] and +//! [`PRICE_TOLERANCE`]. A model that lands on the right price by a different path has not +//! reproduced the rule. Which PRINT the model would have sold on is not judged: that 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. A stop is the one +//! exit judged by its firing: it is a market order on the print, not a resting line. use super::exit::ExitModel; use super::line::LinePoint; -use super::{Deal, EntryParams, ExitKind, ExitParams, Fill, PRICE_TOLERANCE, simulate}; +use super::mshot::MshotParams; +use super::{Deal, EntryParams, Exit, ExitKind, ExitParams, Fill, PRICE_TOLERANCE, simulate}; use crate::feed::types::Tick; /// How far apart a modelled and an archived replacement may be in time and still be the same @@ -30,6 +43,15 @@ pub const POINT_TIME_TOLERANCE_MS: i64 = 1_000; /// fired it. Measured on the live tape (2026-09-20): 0.16–0.28 % between the two on a spike. pub const STOP_PRICE_TOLERANCE: f64 = 0.003; +/// How much BETTER than the modelled level the fact's fill may be and still be that level's +/// fill: a limit never fills worse than its price, and on a gap it fills better — GUN +/// 2026-09-21, the line at 0.003042 sold at 0.0030485, 0.21 % above it, the archive showing +/// the same three moves the model made. Bounded, because a fill far beyond the level is +/// another rule's exit, not a lucky fill of this one — and taken only when the archive holds +/// the trade's Exit line and the model re-placed at every move of it: without that +/// corroboration a wrong rule landing within the allowance would pass as a lucky fill. +pub const FILL_IMPROVEMENT_TOLERANCE: f64 = 0.003; + /// One trade's reproduction verdict, per group. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Verdict { @@ -38,9 +60,11 @@ pub struct Verdict { pub entry: Option, /// Modelled fill against the fact, per cent of the fact (`None` when unfilled or unmodelled). pub entry_dev_pct: Option, - /// Exit reproduced; `None` when no exit rule decided (the exit was taken from the fact), - /// when there was no fill to exit from, or when the core closed by a rule the model does - /// not have yet (`sellreason` is not the take's) — the two prices are not comparable then. + /// Exit reproduced — the line's level at the close against the price the core sold at, + /// and every archived move re-placed; `None` when the core closed by a rule other than the + /// one the model's line was under at the close (a take against an "Auto Price Down" + /// fact, a line against a stop the model never fired) — the two prices are not comparable + /// then. `Some(false)` when no level stood at the close at all. pub exit: Option, /// Modelled exit against the fact, per cent of the fact. pub exit_dev_pct: Option, @@ -69,7 +93,8 @@ fn deviation_pct(modelled: f64, fact: f64) -> Option { /// entry: The entry parameters at the trade — [`EntryParams::Fact`] for a kind without a /// model, which leaves `Verdict::entry` at `None`. /// exit: The sell-line parameters at the trade. -/// entry_start: The archived first point of the entry line, when known. +/// entry_line: The archived Entry line's `(t_ms, price)` points, when the archive holds +/// them; the entry model starts where they say the order stood. /// exit_points: The archived Exit line's `(t_ms, price)` points, when the archive holds /// them; the modelled line must re-place at each. pub fn verify( @@ -77,17 +102,16 @@ pub fn verify( ticks: &[Tick], entry: &EntryParams, exit: &ExitParams, - entry_start: Option<(i64, f64)>, + entry_line: Option<&[(i64, f64)]>, exit_points: Option<&[(i64, f64)]>, ) -> Verdict { - let outcome = simulate(deal, ticks, entry, exit, entry_start); - let entry_modelled = !matches!(entry, EntryParams::Fact); - let (entry_ok, entry_dev) = match (entry_modelled, outcome.fill) { - (false, _) => (None, None), - (true, None) => (Some(false), None), - (true, Some(fill)) => { + let outcome = simulate(deal, ticks, entry, exit, entry_line); + let (entry_ok, entry_dev) = match (entry, outcome.fill) { + (EntryParams::Fact, _) => (None, None), + (EntryParams::MoonShot(_), None) => (Some(false), None), + (EntryParams::MoonShot(params), Some(fill)) => { let dev = deviation_pct(fill.price, deal.buy_price); - let ok = dev.is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0); + let ok = dev.is_some_and(|d| d.abs() <= entry_tolerance_pct(params, deal)); (Some(ok), dev) } }; @@ -102,11 +126,58 @@ pub fn verify( t_ms: deal.buy_ms, price: deal.buy_price, }; - let walked = ExitModel::new(exit).walk(deal, ticks, fact_fill); - let closed = walked.exit; + // The line is walked HELD through the close: its levels are what is judged, and a print + // that would have sold the model's line earlier is the queue's business, not the rule's. + // A kind without a take rule of its own starts at the core's archived take. + let fact_exit = ExitParams { + take_from_archive: true, + ..exit.clone() + }; + let walked = ExitModel::new(&fact_exit).walk_held(deal, ticks, fact_fill, deal.close_ms); + // What the model is held to: a stop it fired by the close (within the point tolerance — + // the model stamps a print, the core its own moment) is the stop's own print, and so is + // a stop it fired later when the core's own exit WAS a stop — a late stop is a timing + // miss of the stop rule, judged by its price, never an unanswered question. Otherwise + // the line as it stood when the core sold — the last level the exchange had been given + // by then, the model's own latency allowed for — and the rule is the take when no move + // had reached the exchange, the moving line otherwise. + let fact_stopped = reason_starts_with(deal.sell_reason.trim(), REASON_STOP); + let closed = match walked.exit.kind { + ExitKind::Stop + if walked.exit.t_ms <= deal.close_ms + POINT_TIME_TOLERANCE_MS || fact_stopped => + { + walked.exit + } + _ => { + // A point the model stamps up to its own latency after the close is a move due + // before it — the core's stamp is its moment, the model's the print plus latency. + let mut placed: Vec<&LinePoint> = walked + .points + .iter() + .filter(|p| p.t_ms <= deal.close_ms + exit.latency_ms.max(0.0) as i64) + .collect(); + // In time order: the take is stamped when it is armed, after any timer step + // that fell due inside the sell delay. + placed.sort_by_key(|p| p.t_ms); + match placed.last() { + Some(level) => Exit { + t_ms: deal.close_ms, + price: level.price, + kind: if placed.len() > 1 { + ExitKind::Line + } else { + ExitKind::Take + }, + }, + // No level placed by the close — the sell delay outlived the trade: the + // walk's own end, `OpenAtWindowEnd` or a stop fired later against a fact + // that was not a stop, which `exit_rule_matches` leaves unanswered. + None => walked.exit, + } + } + }; let (exit_ok, exit_dev, line_points) = if closed.kind == ExitKind::OpenAtWindowEnd { - // The core closed it; the model never did inside the same tape: a miss of the exit - // group, not an unanswered question. + // No line stood at the close: a miss of the exit group, not an unanswered question. (Some(false), None, None) } else if exit_rule_matches(closed.kind, &deal.sell_reason) { let dev = deviation_pct(closed.price, deal.sell_price); @@ -115,12 +186,38 @@ pub fn verify( } else { PRICE_TOLERANCE }; - let price_ok = dev.is_some_and(|d| d.abs() <= tolerance * 100.0); + // Within the tolerance either way, or a limit's fill on the better side of its level: + // `dev` is the model against the fact, so a fact above the modelled sell (a long) or + // below the modelled buy-back (a short) reads as a negative deviation of the model. + let improved = |d: f64| match closed.kind { + ExitKind::Stop => false, + _ => { + let better = if deal.is_long() { -d } else { d }; + better > 0.0 && better <= FILL_IMPROVEMENT_TOLERANCE * 100.0 + } + }; + // The archive's last point AT the sale — within the model's latency of the close, at + // the price the core sold at (GUN 2026-09-21: 31 ms before it, at the average fill) + // — is the fill filed as a point, not a move of the line; a re-placement any earlier, + // or at another price, is a move the model has to have made. + let fill_window_ms = exit.latency_ms.max(0.0) as i64; let points = exit_points.filter(|p| !p.is_empty()).map(|archived| { - let moves = archived_replacements(archived); + let mut moves = archived_replacements(archived); + if moves.len() > 1 + && moves.last().is_some_and(|&(t, p)| { + (t - deal.close_ms).abs() <= fill_window_ms + && deviation_pct(p, deal.sell_price) + .is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0) + }) + { + moves.pop(); + } (matched_points(&walked.points, &moves), moves.len()) }); let line_ok = points.is_none_or(|(matched, total)| matched == total); + let corroborated = points.is_some_and(|(matched, total)| matched == total); + let price_ok = + dev.is_some_and(|d| d.abs() <= tolerance * 100.0 || (corroborated && improved(d))); (Some(price_ok && line_ok), dev, points) } else { (None, None, None) @@ -136,6 +233,14 @@ pub fn verify( } } +/// How far a modelled entry may sit from the fact and still be the same order, per cent: the +/// corridor's own width (`MShotPrice − MShotPriceMin` with the trade's modifiers), floored at +/// [`PRICE_TOLERANCE`] — see the module doc. +pub fn entry_tolerance_pct(params: &MshotParams, deal: &Deal) -> f64 { + let (near, far) = params.bounds_pct(&deal.deltas); + (far - near).max(PRICE_TOLERANCE * 100.0) +} + /// The replacements an archived line records: its first point and every point whose price /// differs from the one before it. The core files a line as a polyline — each move as the /// old level's end and the new level's start at the same instant — so the moves are the @@ -181,9 +286,7 @@ pub const REASON_STOP: &str = "StopLoss"; /// SellLevel / SellShot reasons, the stop against "StopLoss …". fn exit_rule_matches(kind: ExitKind, sell_reason: &str) -> bool { let reason = sell_reason.trim(); - let starts = |prefix: &str| { - reason.len() >= prefix.len() && reason[..prefix.len()].eq_ignore_ascii_case(prefix) - }; + let starts = |prefix: &str| reason_starts_with(reason, prefix); match kind { ExitKind::Take => reason.eq_ignore_ascii_case(REASON_TAKE), ExitKind::Line => REASONS_LINE.iter().any(|r| starts(r)), @@ -192,6 +295,15 @@ fn exit_rule_matches(kind: ExitKind, sell_reason: &str) -> bool { } } +/// Whether a `sellreason` starts with an ASCII prefix, case-insensitively — on characters, +/// never bytes: the reason is database text, and a slice at a byte inside a multi-byte +/// character would panic. +fn reason_starts_with(reason: &str, prefix: &str) -> bool { + reason + .get(..prefix.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) +} + /// Share of ✓ over verdicts that answered, as `(hits, answered)`; the caption prints it and /// the gate compares it with the threshold. Unanswered verdicts are out of both counts. pub fn share(verdicts: impl IntoIterator>) -> (usize, usize) { From a7876fcee2f098e9412bfb065407142f76a100f2 Mon Sep 17 00:00:00 2001 From: guyverino Date: Mon, 21 Sep 2026 15:02:43 +0200 Subject: [PATCH 15/51] feat(tuner): core column, status line and a "tunable only" switch on the deal table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../src/analytics/tuner/ticks/columns.rs | 26 +- .../src/analytics/tuner/ticks/fetch.rs | 2 +- .../src/analytics/tuner/ticks/fetch/job.rs | 2 +- .../src/analytics/tuner/ticks/load.rs | 28 +- .../src/analytics/tuner/ticks/mod.rs | 275 ++++++++++-------- .../src/analytics/tuner/ticks/rows.rs | 28 +- .../src/analytics/tuner/ticks/rows/tests.rs | 36 ++- .../src/analytics/tuner/ticks/state.rs | 33 ++- .../src/analytics/tuner/ticks/variants.rs | 2 +- locales/analytics.yml | 20 +- 10 files changed, 287 insertions(+), 165 deletions(-) diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs index 58a2b72c..3e90dbe6 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs @@ -1,6 +1,8 @@ //! Columns of the DEAL table of the "Entry/Exit" axis: what each shows, its width, and the //! sort it offers. Hand-laid like the coin table, because the two trailing cells (tape, model) -//! are marks, not metrics, and no shared descriptor draws a mark. +//! are marks, not metrics, and no shared descriptor draws a mark. The heading and the row +//! cells are built from the SAME descriptor through one box (`ticks::deal_cell`), so they +//! cannot drift apart horizontally. /// One column of the deal table. pub(in crate::analytics::tuner) struct DealCol { @@ -24,6 +26,9 @@ pub(in crate::analytics::tuner) enum Align { } pub(in crate::analytics::tuner) const COL_COIN: &str = "coin"; +pub(in crate::analytics::tuner) const COL_CORE: &str = "core"; +/// The entry time — the table's default order (newest first), not a column: a deal is found +/// by its coin and core, and the time is one double-click away in the trade window. pub(in crate::analytics::tuner) const COL_TIME: &str = "time"; pub(in crate::analytics::tuner) const COL_RESULT: &str = "result"; pub(in crate::analytics::tuner) const COL_PROFIT: &str = "profit"; @@ -43,19 +48,14 @@ const fn col(key: &'static str, label: &'static str, w: f32, min_w: f32, align: } } -/// The columns after the coin, in reading order: when, what came of it (per cent and money), -/// how long it was held, how much tape the terminal holds around it, why it closed, and the -/// two marks of this axis. The prices and the market deltas were dropped on 2026-09-20: this -/// table is the axis's SAMPLE — which trades have their tape and how the model does on them — -/// and every other figure is one double-click away in the trade window. +/// The columns after the coin, in reading order: whose core, what came of it (per cent and +/// money), how long it was held, how much tape the terminal holds around it, why it closed, +/// and the two marks of this axis. The prices and the market deltas were dropped on +/// 2026-09-20, the entry time on 2026-09-21: this table is the axis's SAMPLE — which trades +/// have their tape and how the model does on them — and every other figure is one +/// double-click away in the trade window. pub(in crate::analytics::tuner) const DEAL_COLS: &[DealCol] = &[ - col( - COL_TIME, - "analytics.ticks.col.time", - 64.0, - 56.0, - Align::Right, - ), + col(COL_CORE, "analytics.col.core", 72.0, 48.0, Align::Left), col( COL_RESULT, "analytics.ticks.col.result", diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index a3130a6f..56ffffc4 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -311,7 +311,7 @@ impl AnalyticsView { slot.verdict = answer.verdict; slot.deal.tick = answer.deal.tick; slot.ticks = answer.ticks; - slot.entry_start = answer.entry_start; + slot.entry_line = answer.entry_line; }); // A row joined the replayable set: the variant columns are due a rescore. self.arm_ticks_variants(cx); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs index 435c3044..70317eb6 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -725,7 +725,7 @@ fn serve_cluster( verdict: None, address: Some(row.address.clone()), ticks: None, - entry_start: None, + entry_line: None, held: None, }; replay_row(&mut answer, defaults, lines, row.window.long_position_ms); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 47d8d791..adfdb536 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -212,7 +212,7 @@ impl AnalyticsView { verdict: None, address, ticks: None, - entry_start: None, + entry_line: None, held: None, } }) @@ -294,7 +294,7 @@ impl AnalyticsView { verdict: None, address: Some(address), ticks: None, - entry_start: None, + entry_line: None, held: None, }) .collect(); @@ -458,11 +458,11 @@ fn now_values(targets: &[(i64, Option)], keys: &[String]) -> HashMap, + pub(super) entry_points: Option>, pub(super) exit_points: Option>, } @@ -472,16 +472,16 @@ impl ArchivedLines { let TraceEntry::Lines(lines) = entry else { return Self::default(); }; - let entry_start = lines + let entry_points = lines .iter() .find(|l| l.own && l.kind == ArchivedLineKind::Entry) - .and_then(|l| l.points.first().map(|&(t, p)| (t as i64, p))); + .map(|l| l.points.iter().map(|&(t, p)| (t as i64, p)).collect()); let exit_points = lines .iter() .find(|l| l.own && l.kind == ArchivedLineKind::Exit) .map(|l| l.points.iter().map(|&(t, p)| (t as i64, p)).collect()); Self { - entry_start, + entry_points, exit_points, } } @@ -565,7 +565,7 @@ pub(super) fn replay_row_with( tape: Option, ) { row.ticks = None; - row.entry_start = lines.entry_start; + row.entry_line = lines.entry_points.clone(); row.held = None; let Some(address) = row.address.clone() else { return; @@ -612,12 +612,20 @@ pub(super) fn replay_row_with( EntryParams::Fact }; let exit = params::exit_params(&sv); + // The ask the core lifted its take to, off the archive — the tape has no book. + row.deal.pre_spike_ask = moon_core::db::tuner::ticks::archived_pre_spike_ask( + lines.exit_points.as_deref(), + &exit, + row.deal.is_short, + ); + row.deal.archived_take = + moon_core::db::tuner::ticks::archived_take(lines.exit_points.as_deref()); row.verdict = Some(verify( &row.deal, &ticks, &entry, &exit, - lines.entry_start, + lines.entry_points.as_deref(), lines.exit_points.as_deref(), )); row.ticks = Some(Arc::from(ticks)); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index 78abe02e..57ccea55 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -3,9 +3,11 @@ //! //! Left, under the strategy list: the deal table — one row per closed trade with millisecond //! stamps, what came of it, whether the terminal holds its tape, and whether the model -//! reproduces the fact; a double-click opens the trade window on it. Right: the shared "Fact vs …" matrix (the whole -//! scope, the replayable subset captioned with the ✓ shares, the variant columns), and the -//! parameter grid with the strategies' values, the two variant columns and the search row. +//! reproduces the fact; a double-click opens the trade window on it. By default it shows only +//! the rows the tuner counts (tape held, fact reproduced), and its status line says how many +//! that is out of the scope. Right: the shared "Fact vs …" matrix (the whole scope, the +//! replayable subset captioned with the ✓ shares, the variant columns), and the parameter grid +//! with the strategies' values, the two variant columns and the search row. //! //! The model itself is `moon_core::db::tuner::ticks`; this module only feeds it and draws //! what it says. @@ -13,8 +15,8 @@ use gpui::prelude::FluentBuilder; use gpui::*; use moon_ui::{ - MoonButton, MoonButtonVariant, MoonPalette, MoonScrollbarVisibility, MoonTooltipView, - MoonVirtualList, h_flex, v_flex, + MoonButton, MoonButtonVariant, MoonCheckbox, MoonPalette, MoonScrollbarVisibility, + MoonTooltipView, MoonVirtualList, h_flex, v_flex, }; use rust_i18n::t; @@ -49,10 +51,11 @@ impl AnalyticsView { ) -> AnyElement { let scale = design::font_scale(cx); let scope = self.scope_label(); - let zone = self.query().axis.zone(); // The order is settled before the data is viewed: both live in `ticks`, and the sort - // cache needs the mutable half. + // cache needs the mutable half. It is the SHOWN rows: with the switch on, only the + // ones the tuner counts. let drawn = rows::order_for(&mut self.ticks).len(); + let only_tunable = self.ticks.only_tunable; // The rows the scope holds but the table does not show, for the caption and for the // empty state: a period entirely before the millisecond stamps is not an empty period, // and the table must say which it is rather than draw the shared "no trades". @@ -68,9 +71,10 @@ impl AnalyticsView { d.covered(), d.fetchable().count(), d.without_ms, + d.tunable(), ) }); - let (body, total, covered, fetchable, without_ms) = match summary { + let (body, total, covered, fetchable, without_ms, tunable) = match summary { Err(crate::load_state::Note::Empty) if left_out != (0, 0, 0) => ( crate::load_state::muted( t!( @@ -88,6 +92,7 @@ impl AnalyticsView { 0usize, 0usize, left_out.0, + 0usize, ), Err(note) => ( super::super::note_el("an-ticks-note", note, 10.0, p, cx), @@ -95,8 +100,29 @@ impl AnalyticsView { 0usize, 0usize, 0usize, + 0usize, ), - Ok((total, covered, fetchable, without_ms)) => { + // Rows loaded, none shown: the switch hid every one. Said in words, with the + // count, rather than drawn as a blank list — while the tape stage still reads, + // the verdicts are not in yet and the note says that instead. + Ok((total, covered, fetchable, without_ms, tunable)) if drawn == 0 => ( + crate::load_state::muted( + if self.ticks.tape_reading { + t!("analytics.ticks.fetch_reading").to_string() + } else { + t!("analytics.ticks.none_tunable", hidden = total).to_string() + }, + 10.0, + p, + cx, + ), + total, + covered, + fetchable, + without_ms, + tunable, + ), + Ok((total, covered, fetchable, without_ms, tunable)) => { let weak = cx.entity().downgrade(); let row_h = deal_row_h(cx); let list = @@ -110,7 +136,7 @@ impl AnalyticsView { let order = view.ticks.order.as_ref()?; let row = view.ticks.data.data()?.rows.get(*order.order.get(ix)?)?; - Some(deal_row(row, weak.clone(), p, scale, row_h, zone, app)) + Some(deal_row(row, weak.clone(), p, scale, row_h, app)) }) .unwrap_or_else(|| div().into_any_element()) }) @@ -119,7 +145,7 @@ impl AnalyticsView { .radius(0.0) .scrollbar_visibility(MoonScrollbarVisibility::Hover) .into_any_element(); - (list, total, covered, fetchable, without_ms) + (list, total, covered, fetchable, without_ms, tunable) } }; // The batch is the process's (`fetch::job`), not this window's: the caption reads its @@ -136,7 +162,8 @@ impl AnalyticsView { self.attach_fetch_listener(cx); } // The button is only the switch — "fetch" or "stop"; what the batch is doing goes into - // the caption beside it, where the sample's coverage sits when nothing runs. + // the caption beside it, and ONLY that: the sample's coverage sits in the status line + // under the table, so the head says nothing when nothing runs. let caption = if fetch_active && !progress.in_flight.is_empty() { // Every market a request is out for, in the order they went out: the walks run in // parallel across venues, and one name would read as one request. @@ -162,8 +189,41 @@ impl AnalyticsView { } else if self.ticks.tape_reading { t!("analytics.ticks.fetch_reading").to_string() } else { - coverage_caption(covered, total, without_ms, left_out.1, left_out.2) + String::new() + }; + // The status line: the honest size of the sample — how many rows the tuner counts, + // how many have their tape, out of how many — with what the scope holds beyond the + // table, and the switch that hides the rest. + let status = { + let mut line = t!("analytics.ticks.tunable_n", n = tunable).to_string(); + line.push_str(" · "); + line.push_str(&coverage_caption( + covered, total, without_ms, left_out.1, left_out.2, + )); + line }; + let only_tip = t!("analytics.ticks.only_tunable_tip").to_string(); + let only_switch = div() + .id("an-ticks-only-tunable-box") + .flex_none() + .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(only_tip.clone())).into()) + .child( + MoonCheckbox::new("an-ticks-only-tunable") + .label(t!("analytics.ticks.only_tunable").to_string()) + .checked(only_tunable) + .on_change({ + let view = cx.entity(); + move |on: &bool, _w, app| { + let on = *on; + view.update(app, |this, cx| { + this.ticks.only_tunable = on; + // The cached order is a permutation of the SHOWN rows. + this.ticks.order = None; + cx.notify(); + }); + } + }), + ); let fetch_label = if fetch_active { t!("analytics.ticks.fetch_stop").to_string() } else { @@ -203,17 +263,18 @@ impl AnalyticsView { .text_color(moon(p.text_muted)) .child(scope), ) - // "N with tape of M · K without stamps": the honest size of the sample, - // with the service rows and the switch's leftovers when there are any — or, - // while a batch runs, how far it is and which markets it is on. - .child( - div() - .flex_none() - .font_family(design::ui_font()) - .text_size(design::t_caption(cx)) - .text_color(moon(p.text_muted)) - .child(caption), - ) + // While a batch runs: how far it is and which markets it is on; while the + // tape stage reads: that. Nothing otherwise — the status line has the rest. + .when(!caption.is_empty(), |el| { + el.child( + div() + .flex_none() + .font_family(design::ui_font()) + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_muted)) + .child(caption), + ) + }) .when(fetchable > 0 || fetch_active, |el| { el.child( div().font_family(design::ui_font()).child( @@ -244,6 +305,23 @@ impl AnalyticsView { .child(self.deal_header(p, cx)) // The virtual list owns its own scrolling. .child(div().w_full().flex_1().min_h_0().child(body)) + .child( + h_flex() + .w_full() + .flex_none() + .h(design::fit_h_px(cx, 26.0, 12.0, 7.0)) + .px(design::ui_px(cx, DEAL_ROW_PAD_X)) + .items_center() + .gap(design::ui_px(cx, 8.0)) + .border_t_1() + .border_color(moon(p.border)) + .bg(moon(p.table_head)) + .font_family(design::ui_font()) + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_muted)) + .child(div().flex_1().min_w_0().truncate().child(status)) + .child(only_switch), + ) .into_any_element() } @@ -251,8 +329,8 @@ impl AnalyticsView { /// the two lists cannot disagree about what a trade is. /// /// Silent when the row has no address (its core is offline, or the coin resolves to no - /// market): the tape dot at the row's left edge already says so, and a double-click has - /// nowhere to put a reason. + /// market): the row's tape mark already says so, and a double-click has nowhere to put a + /// reason. /// /// Args: /// report_uid: The row's `ReportUID` — the core's own key for the trade, not the replica's row id. @@ -292,42 +370,31 @@ impl AnalyticsView { crate::trade_window::open_record::open_trade_record(&self.backend, q.axis, target, cx); } - /// The table's heading row: every column sortable, the arrow on the active one. + /// The table's heading row: every column sortable, the arrow on the active one. Each + /// heading sits in the same box as the column's cells ([`deal_cell`], [`coin_cell`]), so + /// the two rows share every width, minimum and shrink rule and cannot drift apart. fn deal_header(&self, p: MoonPalette, cx: &Context) -> impl IntoElement + use<> { let scale = design::font_scale(cx); let sortable = |id: SharedString, title: String, key: &'static str, col: Option<&DealCol>| { let arrow = sort_arrow_of(&self.ticks.sort, key); - let d = div() - .id(id) - .flex_none() - .truncate() - .cursor_pointer() - .text_color(if arrow.is_empty() { - moon(p.text_soft) - } else { - moon(p.amber) - }) - .child(format!("{title}{arrow}")) - .on_click(cx.listener(move |this, _, _, cx| { - toggle_sort_key(&mut this.ticks.sort, key); - this.ticks.order = None; - cx.notify(); - })); match col { - Some(col) => { - let d = d - .w(px(col.w * scale)) - .min_w(px(col.min_w * scale)) - .flex_shrink_1(); - match col.align { - Align::Right => d.text_right(), - Align::Center => d.text_center(), - Align::Left => d, - } - } - None => d.flex_1().min_w(px(DEAL_COIN_MIN_W * scale)), + Some(col) => deal_cell(col, scale), + None => coin_cell(scale), } + .id(id) + .cursor_pointer() + .text_color(if arrow.is_empty() { + moon(p.text_soft) + } else { + moon(p.amber) + }) + .child(format!("{title}{arrow}")) + .on_click(cx.listener(move |this, _, _, cx| { + toggle_sort_key(&mut this.ticks.sort, key); + this.ticks.order = None; + cx.notify(); + })) }; h_flex() .w_full() @@ -623,13 +690,6 @@ fn deal_row_h(cx: &App) -> f32 { design::fit_h_value(cx, 24.0, 14.0, 5.0) } -/// Wall-clock time of a millisecond stamp in the selected zone. -fn hms(unix_ms: i64, zone: chrono_tz::Tz) -> String { - moon_core::util::display_time::at_millis(unix_ms, zone) - .map(|value| value.format("%H:%M:%S").to_string()) - .unwrap_or_default() -} - /// A duration in the shortest unit that keeps it readable. fn duration_text(ms: i64) -> String { let s = ms.max(0) as f64 / 1000.0; @@ -682,41 +742,31 @@ fn tape_mark(tape: TapeStatus) -> (&'static str, String) { } } -/// The tape dot of a row: filled in the state's colour, hollow while the tape is missing — -/// the same distinction the column's ●/○ draws, readable at any table width. -fn tape_dot( - tape: TapeStatus, - tip: String, - report_uid: i64, - p: MoonPalette, - scale: f32, -) -> AnyElement { - let (color, filled) = match tape { - TapeStatus::Covered => (p.green, true), - TapeStatus::Fetching => (p.amber, true), - TapeStatus::Refused(_) => (p.red, true), - TapeStatus::NoAddress => (p.text_muted, true), - TapeStatus::Missing => (p.text_muted, false), - }; - let size = px(TAPE_DOT_PX * scale); - let dot = div().size(size).rounded_full().flex_none(); - let dot = if filled { - dot.bg(moon(color)) - } else { - dot.border_1().border_color(moon(color)) - }; - div() - .id(SharedString::from(format!("an-ticks-dot-{report_uid}"))) +/// The box of one column's cell — the heading's and the row's alike: the descriptor's width +/// and minimum, shrinking with the table past its preferred width, the text cut rather than +/// wrapped, aligned as the column says. One builder for both rows is what keeps the headings +/// over their cells; the two used to set the shrink rule differently and drifted apart on a +/// narrow table. +fn deal_cell(col: &DealCol, scale: f32) -> Div { + let el = div() .flex_none() - .child(dot) - .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(tip.clone())).into()) - .into_any_element() + .flex_shrink_1() + .w(px(col.w * scale)) + .min_w(px(col.min_w * scale)) + .truncate(); + match col.align { + Align::Right => el.text_right(), + Align::Center => el.text_center(), + Align::Left => el, + } } -/// Diameter of the row's tape dot, in base px, before the font scale. -const TAPE_DOT_PX: f32 = 7.0; +/// The box of the coin column — the flexible remainder, with the same floor in both rows. +fn coin_cell(scale: f32) -> Div { + div().flex_1().min_w(px(DEAL_COIN_MIN_W * scale)).truncate() +} -/// The header caption of the table: how many rows have their tape, out of how many, and what +/// The status line's coverage part: how many rows have their tape, out of how many, and what /// the scope holds beyond the table — rows without millisecond stamps always, the service rows /// and the untunable ones only when there are any. fn coverage_caption( @@ -776,25 +826,12 @@ fn deal_row( p: MoonPalette, scale: f32, row_h: f32, - zone: chrono_tz::Tz, cx: &App, ) -> AnyElement { let d = &row.deal; let result = rows::result_pct(row); let cell = |col: &DealCol, text: String, color: u32, tip: Option| { - let mut el = div() - .w(px(col.w * scale)) - .min_w(px(col.min_w * scale)) - .flex_shrink_1() - .flex_none() - .truncate() - .text_color(moon(color)) - .child(text); - el = match col.align { - Align::Right => el.text_right(), - Align::Center => el.text_center(), - Align::Left => el, - }; + let el = deal_cell(col, scale).text_color(moon(color)).child(text); match tip { Some(tip) if !tip.is_empty() => el .id(SharedString::from(format!( @@ -819,20 +856,20 @@ fn deal_row( .bg(moon(p.table_body)) .border_t_1() .border_color(moon_alpha(p.border, 0.5)) - // The tape's state as a dot at the LEFT edge, before the coin: the tape column sits - // last and is the first thing a narrow table cuts off, and whether a row has its tape - // is the one thing about it this axis is for. Same tooltip as the column's mark. - .child(tape_dot(row.tape, tape_tip.clone(), d.report_uid, p, scale)) - .child( - div() - .flex_1() - .min_w(design::font_w_px(cx, DEAL_COIN_MIN_W)) - .truncate() - .child(d.coin.clone()), - ); + .child(coin_cell(scale).child(d.coin.clone())); for col in DEAL_COLS { let (value, color, tip) = match col.key { - COL_TIME => (hms(d.buy_ms, zone), text, None), + // The core by the name the report carries; a row that carries none names it by + // its uid, which is still an address. + COL_CORE => ( + if d.core_name.trim().is_empty() { + format!("#{}", d.core_uid) + } else { + d.core_name.clone() + }, + p.text_muted, + None, + ), COL_RESULT => ( format!("{result:+.2}"), if result > 0.0 { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs index 790f4433..ef5978f9 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs @@ -1,6 +1,6 @@ -//! The deal table's row order — a permutation over the loaded rows, cached against the data -//! generation and the sort, so a repaint that changed neither reuses it instead of sorting -//! hundreds of deals per frame. +//! The deal table's row order — a permutation over the loaded rows, filtered by the "tunable +//! only" switch and cached against the data generation, the sort and the switch, so a repaint +//! that changed none of them reuses it instead of sorting hundreds of deals per frame. use super::columns::*; use super::state::{DealRow, TapeStatus, TicksState}; @@ -9,25 +9,29 @@ use super::state::{DealRow, TapeStatus, TicksState}; pub(in crate::analytics::tuner) struct OrderCache { pub(in crate::analytics::tuner) rows_rev: u64, pub(in crate::analytics::tuner) sort: Option<(String, bool)>, - /// Indices into `TicksData::rows`. + pub(in crate::analytics::tuner) only_tunable: bool, + /// Indices into `TicksData::rows` — the shown rows only, when the switch hides the rest. pub(in crate::analytics::tuner) order: Vec, } -/// The current order, rebuilt only when the rows or the sort changed. +/// The current order, rebuilt only when the rows, the sort or the "tunable only" switch +/// changed. pub(in crate::analytics::tuner) fn order_for(state: &mut TicksState) -> &[usize] { - let fresh = state - .order - .as_ref() - .is_some_and(|c| c.rows_rev == state.rows_rev && c.sort == state.sort); + let fresh = state.order.as_ref().is_some_and(|c| { + c.rows_rev == state.rows_rev && c.sort == state.sort && c.only_tunable == state.only_tunable + }); if !fresh { let rows: &[DealRow] = state.data.data().map(|d| d.rows.as_slice()).unwrap_or(&[]); - let mut order: Vec = (0..rows.len()).collect(); + let mut order: Vec = (0..rows.len()) + .filter(|&i| !state.only_tunable || rows[i].tunable()) + .collect(); if let Some((key, desc)) = &state.sort { sort_indices(rows, &mut order, key, *desc); } state.order = Some(OrderCache { rows_rev: state.rows_rev, sort: state.sort.clone(), + only_tunable: state.only_tunable, order, }); } @@ -87,6 +91,10 @@ fn sort_indices(rows: &[DealRow], order: &mut [usize], key: &str, desc: bool) { let c = rows[a].deal.coin.cmp(&rows[b].deal.coin); if desc { c.reverse() } else { c } }), + COL_CORE => order.sort_by(|&a, &b| { + let c = rows[a].deal.core_name.cmp(&rows[b].deal.core_name); + if desc { c.reverse() } else { c } + }), COL_RESULT => by_f64(&result_pct, order), // Unpriced sorts as the smallest. COL_PROFIT => by_f64(&|r| r.deal.profit.unwrap_or(f64::MIN), order), diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index ad6b14ba..165a6e3a 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -8,6 +8,7 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { Deal { report_uid: uid, core_uid: 1, + core_name: String::new(), strategy_id: 1, kind: "MoonShot".into(), coin: "ACE".into(), @@ -22,6 +23,8 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { profit: None, deltas: Deltas::default(), tick: None, + pre_spike_ask: None, + archived_take: None, } } @@ -45,7 +48,7 @@ fn state() -> TicksState { verdict: None, address: None, ticks: None, - entry_start: None, + entry_line: None, held: None, }, DealRow { @@ -54,7 +57,7 @@ fn state() -> TicksState { verdict: Some(verdict(Some(true), Some(true))), address: None, ticks: None, - entry_start: None, + entry_line: None, held: None, }, DealRow { @@ -63,7 +66,7 @@ fn state() -> TicksState { verdict: Some(verdict(Some(false), None)), address: None, ticks: None, - entry_start: None, + entry_line: None, held: None, }, ]; @@ -76,6 +79,8 @@ fn state() -> TicksState { rows[0].held = Some((60_000, 30_000)); rows[1].held = Some((60_000, 60_000)); let mut state = TicksState::default(); + // The sorts are exercised over every row; the "tunable only" switch has its own test. + state.only_tunable = false; state.data.apply(Ok(TicksData { rows, ..TicksData::default() @@ -100,6 +105,31 @@ fn the_default_order_is_newest_entry_first() { assert_eq!(uids(&mut state), [1, 3, 2]); } +#[test] +fn the_tunable_switch_keeps_only_covered_rows_the_model_reproduced() { + // Row 2: covered, both groups ✓. Row 1: no tape. Row 3: refused, and the entry a miss. + let mut state = state(); + assert!(TicksState::default().only_tunable, "on by default"); + state.only_tunable = true; + assert_eq!(uids(&mut state), [2]); + assert_eq!(state.data.data().unwrap().tunable(), 1); + // A group the model does not answer is not a miss. + state.data.data_mut().unwrap().rows[1].verdict = Some(verdict(None, Some(true))); + state.rows_rev += 1; + assert_eq!(uids(&mut state), [2]); + // Nothing answered is nothing reproduced. + state.data.data_mut().unwrap().rows[1].verdict = Some(verdict(None, None)); + state.rows_rev += 1; + assert_eq!(uids(&mut state), Vec::::new()); + // A group it got wrong is. + state.data.data_mut().unwrap().rows[1].verdict = Some(verdict(Some(true), Some(false))); + state.rows_rev += 1; + assert_eq!(uids(&mut state), Vec::::new()); + // Flipping the switch alone rebuilds the order: the cache keys on it. + state.only_tunable = false; + assert_eq!(uids(&mut state), [1, 3, 2]); +} + #[test] fn a_short_result_is_signed_from_its_own_side() { let state = state(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index a2959bc9..d9472b01 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -61,8 +61,9 @@ pub(in crate::analytics::tuner) struct DealRow { /// The window's prints, kept for the variants and the search while the row is covered and /// the memory cap allows; `None` otherwise. pub(in crate::analytics::tuner) ticks: Option>, - /// The archived first point of the entry line, when the archive holds it. - pub(in crate::analytics::tuner) entry_start: Option<(i64, f64)>, + /// The archived points of the trade's own entry line, when the archive holds it — + /// where the order stood before the tape begins, and the core's first moves. + pub(in crate::analytics::tuner) entry_line: Option>, /// What the terminal HOLDS of the window, as `(lead_ms, trail_ms)`: how far before the /// entry and past the exit the held coverage reaches, clipped to what the window asks for /// (the setting's margin, floored for the model). `None` until the tape stage answered, or @@ -71,6 +72,22 @@ pub(in crate::analytics::tuner) struct DealRow { pub(in crate::analytics::tuner) held: Option<(i64, i64)>, } +impl DealRow { + /// Whether the tuner counts this row: its tape covers the window and the model reproduces + /// the fact in every group it answers, having answered at least one. An unanswered group — + /// a kind without an entry model, an exit rule the model does not have — is not a miss; a + /// group the model got wrong is; a row it verified nothing about is not a reproduction + /// either, and a row without its tape has no verdict at all. + pub(in crate::analytics::tuner) fn tunable(&self) -> bool { + self.tape == TapeStatus::Covered + && self.verdict.is_some_and(|v| { + v.entry != Some(false) + && v.exit != Some(false) + && (v.entry == Some(true) || v.exit == Some(true)) + }) + } +} + /// Where a deal's prints live, as the replay worker keys them, plus what a fetch needs. #[derive(Clone, Debug)] pub(in crate::analytics::tuner) struct RowAddress { @@ -127,6 +144,11 @@ impl TicksData { .count() } + /// Rows the tuner counts — see [`DealRow::tunable`]; the status line's figure. + pub(in crate::analytics::tuner) fn tunable(&self) -> usize { + self.rows.iter().filter(|r| r.tunable()).count() + } + /// The exit horizon of the replayable sample, in milliseconds — the shortest HELD trail /// past the close among the rows the variants and the search replay, the same rule as /// `search::common_horizon_ms` over the same rows (`prepared_deals` hands it each row's @@ -249,6 +271,10 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) dirty: bool, /// `(column key, descending)` of the deal table. pub(in crate::analytics::tuner) sort: Option<(String, bool)>, + /// The table shows only the rows the tuner counts ([`DealRow::tunable`]); on by default, + /// because the rest — no tape yet, a fact the model missed — is what the status line + /// counts, not what the sample is. + pub(in crate::analytics::tuner) only_tunable: bool, /// The sorted row order, cached against `rows_rev` and the sort. pub(in crate::analytics::tuner) order: Option, /// Bumped whenever `data` changes, so the cached order is rebuilt. @@ -291,6 +317,7 @@ impl Default for TicksState { seq: 0, dirty: true, sort: Some((super::columns::COL_TIME.to_string(), true)), + only_tunable: true, order: None, rows_rev: 0, entry_open: true, @@ -451,7 +478,7 @@ impl TicksState { slot.verdict = answer.verdict; slot.deal.tick = answer.deal.tick; slot.ticks = answer.ticks; - slot.entry_start = answer.entry_start; + slot.entry_line = answer.entry_line; slot.held = answer.held; } data.retain_within_cap(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 7581f14d..067c6fc3 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -64,7 +64,7 @@ impl AnalyticsView { Some(PreparedDeal { deal: row.deal.clone(), ticks: row.ticks.clone()?, - entry_start: row.entry_start, + entry_line: row.entry_line.clone(), trail_ms: row.held.map(|(_, trail)| trail).unwrap_or(0), }) }) diff --git a/locales/analytics.yml b/locales/analytics.yml index eec97cb3..f224420e 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1720,6 +1720,22 @@ analytics.ticks.coverage_untunable: ru: "вне тюнинга %{n}" en: "outside tuning %{n}" es: "fuera del ajuste %{n}" +analytics.ticks.tunable_n: + ru: "годных для тюнера %{n}" + en: "tunable %{n}" + es: "aptas para el ajuste %{n}" +analytics.ticks.only_tunable: + ru: "только годные" + en: "tunable only" + es: "solo aptas" +analytics.ticks.only_tunable_tip: + ru: "Показывать только сделки, по которым тюнер считает: лента покрывает окно, и модель воспроизводит вход и выход" + en: "Show only the trades the tuner counts: the tape covers the window, and the model reproduces the entry and the exit" + es: "Mostrar solo las operaciones que el ajuste cuenta: la cinta cubre la ventana y el modelo reproduce la entrada y la salida" +analytics.ticks.none_tunable: + ru: "Годных для тюнера сделок пока нет — %{hidden} скрыто галкой «только годные»" + en: "No tunable trades yet — %{hidden} hidden by \"tunable only\"" + es: "Aún no hay operaciones aptas — %{hidden} ocultas por «solo aptas»" analytics.ticks.empty_left_out: ru: "В периоде нет сделок, по которым ось может считать: без мс-штампа %{without} (старые строки реплики или ядро до штампов — лента по ним не восстанавливается), служебных %{service} (фандинг, ликвидации, объединённые продажи, без стратегии), вне тюнинга %{untunable} (ручные продажи, стратегии без торгового правила)." en: "The period holds no trade the axis can read: %{without} without millisecond stamps (older replica rows, or a core before the stamps — no tape can be tied to them), %{service} service rows (funding, liquidations, joined sells, no strategy), %{untunable} outside tuning (manual sells, strategies without a trading rule)." @@ -1744,10 +1760,6 @@ analytics.ticks.fetch_waiting: ru: "трейды: %{done}/%{total} · ждём биржу" en: "trades: %{done}/%{total} · waiting for the venue" es: "trades: %{done}/%{total} · esperando al exchange" -analytics.ticks.col.time: - ru: "вход" - en: "entry" - es: "entrada" analytics.ticks.col.result: ru: "%" en: "%" From bbb3ffa39a663a5010604b1e9fee9475407b8985 Mon Sep 17 00:00:00 2001 From: guyverino Date: Tue, 22 Sep 2026 20:44:05 +0200 Subject: [PATCH 16/51] feat(tuner): MoonHook's own take, the delta modifiers on sell and stop, and a sample the verdict stops filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../moon-core/src/db/analytics/query/mod.rs | 3 + crates/moon-core/src/db/tuner/ticks/deals.rs | 135 +++++- crates/moon-core/src/db/tuner/ticks/exit.rs | 188 +++++++- crates/moon-core/src/db/tuner/ticks/hook.rs | 84 ++++ .../src/db/tuner/ticks/hook/tests.rs | 51 ++ crates/moon-core/src/db/tuner/ticks/line.rs | 13 +- .../src/db/tuner/ticks/line/tests.rs | 2 + crates/moon-core/src/db/tuner/ticks/mod.rs | 16 + crates/moon-core/src/db/tuner/ticks/mshot.rs | 4 + crates/moon-core/src/db/tuner/ticks/params.rs | 136 +++++- crates/moon-core/src/db/tuner/ticks/scope.rs | 27 ++ .../src/db/tuner/ticks/scope/tests.rs | 16 + crates/moon-core/src/db/tuner/ticks/search.rs | 4 + .../src/db/tuner/ticks/search/tests.rs | 2 + .../src/db/tuner/ticks/stats/tests.rs | 2 + crates/moon-core/src/db/tuner/ticks/tests.rs | 451 +++++++++++++++++- .../src/db/tuner/ticks/tests/real_data.rs | 13 +- crates/moon-core/src/db/tuner/ticks/verify.rs | 15 +- .../src/analytics/tuner/ticks/mod.rs | 56 +-- .../src/analytics/tuner/ticks/rows.rs | 14 +- .../src/analytics/tuner/ticks/rows/tests.rs | 34 +- .../src/analytics/tuner/ticks/state.rs | 37 +- locales/analytics.yml | 34 +- 23 files changed, 1195 insertions(+), 142 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/hook.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/hook/tests.rs diff --git a/crates/moon-core/src/db/analytics/query/mod.rs b/crates/moon-core/src/db/analytics/query/mod.rs index aa21c5a2..0fae2bce 100644 --- a/crates/moon-core/src/db/analytics/query/mod.rs +++ b/crates/moon-core/src/db/analytics/query/mod.rs @@ -502,6 +502,9 @@ const UNIFIED_COLS: &[&str] = &[ // with `quote_rate` below before it meets a projected sum. `lev` is NOT listed: it already // arrives through `tuner::FIELDS`, and naming it twice would project two identical columns. "boughtq", + // Beside `boughtq` so a reader can tell a sale that moved MORE coins than the entry bought + // — a spot position topped up from the wallet balance — from an ordinary one. + "quantity", "buyprice", "sellprice", // Funding is booked as a pseudo-order (`buydate == closedate`, entry price == exit price, diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index a210c542..72a9f8d3 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -14,7 +14,8 @@ use std::collections::HashMap; use rusqlite::Connection; -use super::scope::{is_service_row, is_tunable}; +use super::hook::parse_hook_detect; +use super::scope::{is_service_row, is_tunable, sold_more_than_bought}; use super::{Deal, Deltas}; use crate::db::analytics::Query; use crate::db::read_fail::read_fail_on; @@ -29,8 +30,8 @@ pub struct DealsRead { /// Rows the scope holds that carry no millisecond stamp — older replicas, or a core that /// predates the stamps. In the "Fact" column, not in the replay. pub without_ms: usize, - /// Service rows with stamps — funding, liquidations, joined sells, no strategy — left out; - /// see [`scope`]. + /// Service rows with stamps — funding, liquidations, joined sells, no strategy, and a sale + /// that moved more coins than the entry bought — left out; see [`scope`]. pub service: usize, /// Trades with stamps the tuner cannot be run on — a container or unresolved kind, a manual /// exit — left out; see [`is_tunable`]. @@ -39,7 +40,7 @@ pub struct DealsRead { /// The delta columns in the order [`Deltas`] is filled below; every one is a `FIELDS` column, /// so the unified source projects it (NULL when the replica lacks it). -const DELTA_COLS: [&str; 12] = [ +const DELTA_COLS: [&str; 13] = [ "d5s", "d1m", "d5m", @@ -52,6 +53,7 @@ const DELTA_COLS: [&str; 12] = [ "btc1hdelta", "btc5mdelta", "exchange1hdelta", + "dbtc1m", ]; /// Read the scope's closed trades as deals: the trades the tuner can be run on @@ -69,6 +71,7 @@ pub fn read_deals(q: &Query) -> ReadResult { // change unit with the scope's quote, and the scan's `profitbtc` would. let mut read = crate::db::tuner::read_tuner_rows(q, |conn, q, src| { let mut read = read_on(conn, q, src)?; + overlay_hook_detect(conn, q, &mut read.deals)?; match crate::db::tuner::tuner_source_usdt_on(conn, q)? { Some(usdt_src) => overlay_usdt_profit(conn, q, &usdt_src, &mut read.deals)?, None => log::info!( @@ -113,7 +116,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { "SELECT o.\"reportuid\", o.\"core_uid\", o.\"strategyid\", o.\"coin\", o.\"buydatems\", o.\"closedatems\", o.\"buyprice\", o.\"sellprice\", o.\"spentbtc\", o.\"isshort\", o.\"sellreason\", COALESCE(o.pnl, 0), {deltas}, - o.\"core_name\" + o.\"core_name\", o.\"quantity\", o.\"boughtq\" FROM {src}" ); let mut stmt = conn.prepare(&sql).map_err(|e| read_fail_on(conn, CTX, e))?; @@ -151,8 +154,16 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { out.service += 1; continue; } + // A sale the core topped up from the wallet balance moved coins this trade never + // bought, so its price is an average of something else — counted with the service rows + // rather than replayed (`scope::sold_more_than_bought`). + let name_at = 12 + DELTA_COLS.len(); + if sold_more_than_bought(num(name_at + 1)?, num(name_at + 2)?) { + out.service += 1; + continue; + } let mut deltas = Deltas::default(); - let slots: [&mut f64; 12] = [ + let slots: [&mut f64; 13] = [ &mut deltas.d5s, &mut deltas.d1m, &mut deltas.d5m, @@ -165,6 +176,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { &mut deltas.btc1h, &mut deltas.btc5m, &mut deltas.market1h, + &mut deltas.btc1m, ]; for (offset, slot) in slots.into_iter().enumerate() { *slot = num(12 + offset)?; @@ -174,7 +186,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { report_uid, core_uid: int(1)? as u64, core_name: r - .get::<_, Option>(12 + DELTA_COLS.len()) + .get::<_, Option>(name_at) .map_err(fail)? .unwrap_or_default(), strategy_id, @@ -197,6 +209,9 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { tick: None, pre_spike_ask: None, archived_take: None, + // Filled by `overlay_hook_detect` off the raw report row's comment. + hook_depth_pct: None, + hook_stated_take_pct: None, }); order.push((close_ms, report_uid)); } @@ -208,6 +223,86 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { Ok(out) } +/// Fill the hook numbers of every deal the core wrote a detect for — [`Deal::hook_depth_pct`] +/// and [`Deal::hook_stated_take_pct`], out of the report row's `comment`. +/// +/// Read from the RAW report tables rather than through the unified source: `comment` is a long +/// text column, the unified projection is what every other axis scans, and widening it for one +/// rule of one kind would put that text into every analytics query. The scan is bounded by the +/// same period the deals were read with and by the `Depth:` marker, and only the uids already +/// scanned are kept, so nothing grows with the size of the replica but the rows the axis holds. +/// +/// Keyed by `(core_uid, reportuid)`, never by the uid alone: a report uid is unique WITHIN a +/// core — the order-trace archive keys its own rows by the pair, and this scan can hold several +/// cores at once, where one core's detect would otherwise be pinned onto another core's trade. +/// A source without all three columns (the legacy table) contributes nothing, and so does a +/// failure to read one: a hook trade then simply has no depth, which the model reads as "the +/// take rule of this kind is unknown here" rather than guessing a level. +/// +/// Args: +/// conn: The snapshot the scan ran in. +/// q: The floored query the scan ran with — its period bounds are the parameters. +/// deals: The scanned deals, filled in place. +fn overlay_hook_detect(conn: &Connection, q: &Query, deals: &mut [Deal]) -> ReadResult<()> { + const CTX: &str = "tuner: ticks deals (hook detect)"; + if deals.is_empty() { + return Ok(()); + } + let wanted: std::collections::HashSet<(u64, i64)> = + deals.iter().map(|d| (d.core_uid, d.report_uid)).collect(); + let mut found: HashMap<(u64, i64), super::HookDetect> = HashMap::new(); + for src in crate::db::read_sources_res(conn)? { + if !src.cols.contains("reportuid") + || !src.cols.contains("comment") + || !src.cols.contains("core_uid") + { + continue; + } + let sql = format!( + "SELECT \"core_uid\", \"reportuid\", \"comment\" FROM \"{}\" + WHERE \"closedate\" BETWEEN ?1 AND ?2 AND \"comment\" LIKE '%Depth:%'", + src.table + ); + let mut stmt = conn.prepare(&sql).map_err(|e| read_fail_on(conn, CTX, e))?; + let mut rows = stmt + .query(rusqlite::params![q.from, q.to]) + .map_err(|e| read_fail_on(conn, CTX, e))?; + while let Some(r) = rows.next().map_err(|e| read_fail_on(conn, CTX, e))? { + let key = ( + r.get::<_, Option>(0) + .map_err(|e| read_fail_on(conn, CTX, e))? + .unwrap_or(0) as u64, + r.get::<_, Option>(1) + .map_err(|e| read_fail_on(conn, CTX, e))? + .unwrap_or(0), + ); + if !wanted.contains(&key) { + continue; + } + let comment = r + .get::<_, Option>(2) + .map_err(|e| read_fail_on(conn, CTX, e))? + .unwrap_or_default(); + if let Some(detect) = parse_hook_detect(&comment) { + found.entry(key).or_insert(detect); + } + } + } + for deal in deals.iter_mut() { + if let Some(detect) = found.get(&(deal.core_uid, deal.report_uid)) { + deal.hook_depth_pct = Some(detect.depth_pct); + deal.hook_stated_take_pct = detect.stated_take_pct; + } + } + log::info!( + target: crate::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks deals: hook detect read for {} of {} deal(s)", + found.len(), + deals.len() + ); + Ok(()) +} + /// Fill [`Deal::profit`] of every deal with the row's `profitbtc` off the USDT-valued source /// (`tuner_source_usdt_on`), keyed by `reportuid`. A deal the USDT source does not carry — /// a row that joined the replica between the two scans of one snapshot cannot exist, so this @@ -225,27 +320,35 @@ fn overlay_usdt_profit( deals: &mut [Deal], ) -> ReadResult<()> { const CTX: &str = "tuner: ticks deals (USDT money)"; - let sql = format!("SELECT o.\"reportuid\", COALESCE(o.\"profitbtc\", 0) FROM {usdt_src}"); + // Keyed by the pair, like the hook overlay above: a report uid is unique only WITHIN a + // core, and this scan routinely holds several. + let sql = format!( + "SELECT o.\"core_uid\", o.\"reportuid\", COALESCE(o.\"profitbtc\", 0) FROM {usdt_src}" + ); let mut stmt = conn.prepare(&sql).map_err(|e| read_fail_on(conn, CTX, e))?; let mut rows = stmt .query(rusqlite::params![q.from, q.to]) .map_err(|e| read_fail_on(conn, CTX, e))?; - let mut money: HashMap = HashMap::new(); + let mut money: HashMap<(u64, i64), f64> = HashMap::new(); while let Some(r) = rows.next().map_err(|e| read_fail_on(conn, CTX, e))? { - let uid = r - .get::<_, Option>(0) - .map_err(|e| read_fail_on(conn, CTX, e))? - .unwrap_or(0); + let key = ( + r.get::<_, Option>(0) + .map_err(|e| read_fail_on(conn, CTX, e))? + .unwrap_or(0) as u64, + r.get::<_, Option>(1) + .map_err(|e| read_fail_on(conn, CTX, e))? + .unwrap_or(0), + ); let profit = r - .get::<_, Option>(1) + .get::<_, Option>(2) .map_err(|e| read_fail_on(conn, CTX, e))? .filter(|v| v.is_finite()) .unwrap_or(0.0); - money.insert(uid, profit); + money.insert(key, profit); } let mut unpriced = 0usize; for deal in deals.iter_mut() { - deal.profit = money.get(&deal.report_uid).copied(); + deal.profit = money.get(&(deal.core_uid, deal.report_uid)).copied(); if deal.profit.is_none() { unpriced += 1; } diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index 1e033680..6c495158 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -2,7 +2,10 @@ //! model for every strategy kind — after the entry filled, the exit of any strategy is a //! function of the sell-order rules and the tape. //! -//! The take-profit is `SellPrice` per cent above the fill, raised by `MShotSellAtLastPrice` to +//! The take-profit is `SellPrice` per cent above the fill for every kind but MoonHook, whose +//! take replaces it with `HookSellLevel` per cent of the trade's own detect depth +//! ([`super::hook`]), and both are moved by the Delta-Modifier family (`SellModifier`). It is +//! raised by `MShotSellAtLastPrice` to //! the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the 4-second-old ASK, i.e. before //! the spike"; the model takes the ask the caller recovered from the order archive //! (`Deal::pre_spike_ask`), else reads the last print at least [`PRE_SPIKE_LOOKBACK_MS`] before @@ -11,8 +14,9 @@ //! [`super::line`]. A position nothing closed inside the tape is [`ExitKind::OpenAtWindowEnd`]: //! not a trade, whatever the core's exit was. +use super::hook::{KIND_MOONHOOK, hook_take_pct}; use super::line::{LineWalk, walk, walk_held}; -use super::mshot::{DEFAULT_LATENCY_MS, PRE_SPIKE_LOOKBACK_MS}; +use super::mshot::{DEFAULT_LATENCY_MS, Modifiers, PRE_SPIKE_LOOKBACK_MS}; use super::{Deal, Exit, Fill}; use crate::feed::types::Tick; @@ -29,6 +33,43 @@ pub struct ExitParams { /// `SellDelay` — milliseconds the core waits before placing the sell; prints inside the /// delay cannot fill it. pub sell_delay_ms: f64, + /// `HookSellLevel` — MoonHook's replacement for `SellPrice`: the take in per cent OF THE + /// TRADE'S DETECT DEPTH (`Deal::hook_depth_pct`). 0 means the level is unknown, and the + /// verdict then answers nothing rather than judging the line against a guessed level + /// ([`ExitModel::take_known`]). Ignored by every other kind. + pub hook_sell_level_pct: f64, + /// `HookSellFixed` — the core then takes the distance as `HookSellLevel · depth` per cent + /// "whatever the buy price" (FAQ), which is a different rule from the one below. It is NOT + /// modelled: no live strategy on this machine sets it, so the branch could not be checked + /// against anything, and rather than guess, [`ExitModel::take_known`] reports the take as + /// unknown for such a trade and the verdict answers nothing. Not a grid parameter for the + /// same reason — a knob that moves no column is worse than no knob. + pub hook_sell_fixed: bool, + /// `SellModifier` — the coefficient the summed `Add*` delta modifiers are multiplied by + /// before they move the sell level (FAQ: a summed delta of 5 % with `SellModifier = 0.2` + /// places the sell 1 % higher). 0 leaves the level alone. + /// + /// Applies to EVERY kind, not only to the hook, because the field is the general one on the + /// Delta Modifiers tab. It moves what the model computes for any strategy that sets it, + /// where the field used to be ignored outright. Measured before landing it (118 MoonHook + /// trades, 2026-09-22): the distance between the modelled take and the fact falls from a + /// median of 0.763 pp to 0.116 pp, closer on 98 trades of 118, and the ✓ shares of the + /// kinds that do not set the field did not move. + pub sell_modifier: f64, + /// `MaxModifier` — ceiling on the summed modifiers BEFORE the coefficient: + /// `Min(MaxModifier, Σ Pn · Dn)`. 0 means no ceiling. Live strategies keep it around 70 % + /// (median of 526 that set it), so it rarely binds. + pub max_modifier: f64, + /// `StopLossModifier` — the same summed modifiers, applied to the STOP instead of the sell: + /// the stop goes DEEPER by `StopLossModifier · Σ`. Taken verbatim from the core's own log + /// line, of which 136 were read on this machine (2026-09-22): + /// `StopLoss adjusted [-2.00% - (0.20*1.86=0.37%) => -2.37% ]` — and the arithmetic of all + /// 136 reproduces exactly. Set on 415 of 1869 live strategies, median 0.3. + pub stop_loss_modifier: f64, + /// The `Add*Delta` family of the Delta Modifiers tab — the same shape as MoonShot's + /// `MShotAdd*` corridor modifiers, different fields: these move the ORDER PRICE, those the + /// entry corridor, and one strategy can carry both. + pub sell_mods: Modifiers, // PriceDown pub price_down_timer_s: f64, pub price_down_pct: f64, @@ -62,9 +103,10 @@ pub struct ExitParams { /// Model parameter: how long a replacement of the sell takes to reach the book. pub latency_ms: f64, /// Verdict-only: start the line at the archived take (`Deal::archived_take`) for a kind - /// whose take rule the model does not have. Off for every variant, whose take is the - /// `SellPrice` rule for every kind — so varying it moves every column the same way — and - /// on when the fact is replayed to be judged, where the core's own take is the truth. + /// whose take rule the model does not compute itself. Off for every variant, whose take + /// comes from the RULES — `SellPrice`, or `HookSellLevel · depth` for a MoonHook — so that + /// turning a knob moves the columns; on when the fact is replayed to be judged, where the + /// core's own placed level is the truth and nothing the model derives can beat it. pub take_from_archive: bool, } @@ -76,6 +118,12 @@ impl Default for ExitParams { sell_at_last_price: false, sell_price_adjust_pct: 0.0, sell_delay_ms: 0.0, + hook_sell_level_pct: 0.0, + hook_sell_fixed: false, + sell_modifier: 0.0, + max_modifier: 0.0, + stop_loss_modifier: 0.0, + sell_mods: Modifiers::default(), price_down_timer_s: 0.0, price_down_pct: 0.0, price_down_delay_s: 0.0, @@ -123,14 +171,21 @@ impl<'a> ExitModel<'a> { /// `MShotSellAtLastPrice` is on. Long above, short below. pub fn take_level(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> f64 { // A kind whose take rule the model does not have starts where the core's line did — - // when the fact is being judged; a variant computes the `SellPrice` take for every - // kind (see `ExitParams::take_from_archive`). + // when the fact is being judged; a variant computes the take from the rules for every + // kind (see `ExitParams::take_from_archive`). The archived level is the core's own, + // modifiers and all, so nothing below is added to it. if self.params.take_from_archive && !take_model_for(&deal.kind) { if let Some(take) = deal.archived_take.filter(|t| t.is_finite() && *t > 0.0) { return take; } } - let by_pct = fill.price * self.params.sell_price_pct / 100.0; + // Floored at zero: a modifier deep enough to drive the distance negative would put the + // TAKE on the losing side of the entry and turn every level the line steps down from + // inside out. The rules that legitimately sell below the entry are the moving ones + // (`PriceDownAllowedDrop`, a negative `SellShotDistance`), and they get there by + // stepping down from the take, not by starting underneath it. + let pct = (self.base_take_pct(deal) + self.modifier_pct(deal)).max(0.0); + let by_pct = fill.price * pct / 100.0; let mut take = if deal.is_long() { fill.price + by_pct } else { @@ -153,6 +208,55 @@ impl<'a> ExitModel<'a> { take } + /// The take distance before the modifiers, per cent of the fill: `HookSellLevel` of the + /// trade's own detect depth for a MoonHook, `SellPrice` for every other kind. + /// + /// A hook whose depth or level is unknown falls back to `SellPrice` so the line still has + /// somewhere to step down from — and [`Self::take_known`] answers `false` for it, which is + /// what keeps that fallback out of the verdict. + fn base_take_pct(&self, deal: &Deal) -> f64 { + if deal.kind == KIND_MOONHOOK && self.params.hook_sell_level_pct > 0.0 { + if let Some(depth) = deal.hook_depth_pct.filter(|d| d.is_finite() && *d > 0.0) { + return hook_take_pct(depth, self.params.hook_sell_level_pct); + } + } + self.params.sell_price_pct + } + + /// What the delta modifiers add to the sell level, per cent — the capped sum times + /// `SellModifier`, per the FAQ. + fn modifier_pct(&self, deal: &Deal) -> f64 { + modifier_sum(self.params, deal) * self.params.sell_modifier + } + + /// Whether the model knows where this trade's take stood at all. + /// + /// `SellPrice` is the take of every kind the core has but one, so the answer is `true` + /// unless the trade is a MoonHook — the kind that replaces the field with `HookSellLevel` + /// of its detect depth. A hook answers `true` when the archive hands the level over, or + /// when both the depth and the level are known and `HookSellFixed` is off (the fixed branch + /// computes the distance differently and is not modelled — no live strategy sets it, so it + /// could not be checked against anything). + /// + /// `false` is not "the model was wrong": it is "this kind's rule is not modelled here", and + /// [`super::verify`] then answers the exit group with nothing rather than judging the line + /// against a level the model invented. Measured on the live sample (2026-09-22): reading + /// this wider — every kind without an archived line — silenced five verdicts that + /// `SellPrice` had answered legitimately, four of them hits. + pub fn take_known(&self, deal: &Deal) -> bool { + if deal.kind != KIND_MOONHOOK { + return true; + } + if deal.archived_take.is_some_and(|t| t.is_finite() && t > 0.0) { + return true; + } + !self.params.hook_sell_fixed + && self.params.hook_sell_level_pct > 0.0 + && deal + .hook_depth_pct + .is_some_and(|d| d.is_finite() && d > 0.0) + } + /// Replay the tape after the fill: the take, the moving line, the stop. /// /// Args: @@ -183,10 +287,70 @@ impl<'a> ExitModel<'a> { } } -/// Whether the model has the kind's own take rule — `SellPrice` lifted by -/// `MShotSellAtLastPrice` is MoonShot's; the other kinds place the take by rules of their own -/// that are not modelled, and the verdict takes it from the archive (`Deal::archived_take`, -/// `ExitParams::take_from_archive`). +/// The summed delta modifiers of a trade, capped: `Min(MaxModifier, Σ Pn · Dn)`. +/// +/// One sum, two consumers — the sell level through `SellModifier` and the stop through +/// `StopLossModifier` — because the core computes it once and spends it on both (FAQ, and the +/// `StopLoss adjusted` log line prints that very number). +/// +/// What it cannot be: exact. The coefficients are the strategy's, but the deltas are the ONE +/// snapshot the report stores per trade, while the core re-evaluates them live. Checked against +/// 121 of the core's own printed sums (2026-09-22): the structure reproduces them — a quarter +/// land within 0.02 and 57 % within 0.1 — and the residual grows with how long the entry order +/// waited before it filled, which is the deltas moving under a snapshot taken once. +/// +/// Args: +/// params: The sell parameters, for the coefficients and the ceiling. +/// deal: The trade, for its deltas. +pub fn modifier_sum(params: &ExitParams, deal: &Deal) -> f64 { + let sum = params.sell_mods.near_addition(&deal.deltas); + if params.max_modifier > 0.0 { + sum.min(params.max_modifier) + } else { + sum + } +} + +/// The stop distance of a trade, per cent: `StopLoss` adjusted by `StopLossModifier · Σ`. +/// +/// Normally that deepens the stop (a positive coefficient over a positive delta sum), but +/// neither sign is guaranteed: live strategies carry `StopLossModifier` down to −0.3, and a +/// delta sum can be negative, so the adjustment can also pull the stop TOWARD the entry. +/// +/// An adjustment big enough to pull it THROUGH the entry answers `0.0` — no stop on this trade +/// — rather than a level. Clamping it to a hair's breadth from the entry instead would fire on +/// the first print that moves, which is not a stop but a coin flip dressed as one; and placing +/// it beyond the entry would fire on the first print, full stop. What the core does with an +/// adjustment that large is unknown: no such case appears in the 136 `StopLoss adjusted` lines +/// read off live cores, and none of the 1 735 replayed trades reaches this branch, so the model +/// declines to invent an answer. A configured stop on the profit side (`StopLoss` positive — +/// live data has it) is a different thing and is left exactly as configured. +/// +/// Args: +/// params: The sell parameters. +/// deal: The trade, for its deltas. +pub fn stop_pct(params: &ExitParams, deal: &Deal) -> f64 { + if params.stop_loss_pct == 0.0 || params.stop_loss_modifier == 0.0 { + return params.stop_loss_pct; + } + let adjusted = params.stop_loss_pct - modifier_sum(params, deal) * params.stop_loss_modifier; + // Same side as configured, or nothing at all. + if adjusted == 0.0 || adjusted.is_sign_negative() != params.stop_loss_pct.is_sign_negative() { + return 0.0; + } + adjusted +} + +/// Whether the take the model computes for the kind is the kind's OWN rule, rather than the +/// general `SellPrice` — true for MoonShot, whose `MShotSellAtLastPrice` lift belongs to it +/// alone. For the rest the verdict prefers the archived level when the trade has one +/// (`Deal::archived_take`, `ExitParams::take_from_archive`), because the core's placed level +/// carries the delta modifiers exactly as the core applied them. +/// +/// It is NOT the test for "is the take known at all" — that is [`ExitModel::take_known`], and +/// reading this one in its place silenced five legitimate verdicts on the live sample +/// (2026-09-22), four of them hits: `SellPrice` is the take of every kind but MoonHook, and a +/// Spread trade without an archived line is judged by it perfectly well. pub fn take_model_for(kind: &str) -> bool { super::entry::entry_model_for(kind) } diff --git a/crates/moon-core/src/db/tuner/ticks/hook.rs b/crates/moon-core/src/db/tuner/ticks/hook.rs new file mode 100644 index 00000000..a2335146 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/hook.rs @@ -0,0 +1,84 @@ +//! MoonHook's own take rule, and the numbers the core writes about its detect. +//! +//! A MoonHook carries **no `SellPrice` field at all** — its sell lives in `HookSellLevel`, in +//! per cent OF THE DETECT DEPTH (core FAQ: "HookSellLevel: заменяет SellPrice. Задается в +//! процентах от глубины детекта… 100 % означает продажу в верхней точке, из которой начался +//! прострел"). A model that reads `SellPrice` for a hook gets the schema default and misses +//! every trade: 117 of 118 on the live sample of 2026-09-22. +//! +//! The depth is a property of the TRADE, not of the strategy, and the report carries it only +//! inside the row's `comment`, which the core writes as +//! +//! ```text +//! Hook Long Depth: 2.54% [2.54%] R: 62% d: 2.52% (High: 0.007838 Min: 0.007644 +//! …) InitialPrice: 0.007644 Buffer: [1.25%..1.87%] SellPrice: 1.27% AbsT: 8.3 +//! ``` +//! +//! `Depth` is the detect's own depth and `SellPrice` the level the core actually placed — +//! `HookSellLevel · Depth / 100`, which is how the formula below was checked: it reproduces +//! that number on 90 of 118 trades within 0.01 pp, with the buy price as the base (median of +//! fact against prediction +0.003 %). The remaining 28 all sit HIGHER than the formula, never +//! lower; the depth in the comment is written at close time while the take was placed at fill +//! time, and the detect's state in between is not in the report. See +//! `docs-internal/STRATEGY_FORMULAS/moonhook.md`. +//! +//! The archive cannot stand in for this: the core files a line only when it was RE-PLACED, and +//! a take that never moved has none — 0 of 155 take-closed trades on the same sample carry an +//! Exit line, against 1483 of 1484 for `Auto Price Down`. + +/// Strategy kind name, as `strategies.sqlite` spells it. +pub const KIND_MOONHOOK: &str = "MoonHook"; + +/// What the core wrote about one hook trade's detect, parsed out of the report's `comment`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HookDetect { + /// `Depth: X%` — the detect's depth, per cent. The base of the take rule. + pub depth_pct: f64, + /// `SellPrice: Y%` — the take the core actually placed, per cent from the buy. Not an + /// input of the model: it is the yardstick the formula is checked against, and the fact a + /// variant must NOT be judged by (a variant asks what another `HookSellLevel` would have + /// done, and the core's number answers only for the one it used). + pub stated_take_pct: Option, +} + +/// The take distance of a hook trade, per cent from the fill. +/// +/// Args: +/// depth_pct: The trade's detect depth (`HookDetect::depth_pct`). +/// sell_level_pct: `HookSellLevel` of the strategy, per cent of that depth. +pub fn hook_take_pct(depth_pct: f64, sell_level_pct: f64) -> f64 { + depth_pct * sell_level_pct / 100.0 +} + +/// Read `Depth:` and `SellPrice:` out of a report row's comment. +/// +/// Returns `None` for a comment that carries no hook detect — every other kind's comment, and a +/// hook row whose depth the core did not write. +/// +/// Args: +/// comment: The report row's `comment`, as stored. +pub fn parse_hook_detect(comment: &str) -> Option { + let depth_pct = percent_after(comment, "Depth:")?; + (depth_pct.is_finite() && depth_pct > 0.0).then_some(HookDetect { + depth_pct, + stated_take_pct: percent_after(comment, "SellPrice:").filter(|v| v.is_finite()), + }) +} + +/// The per-cent number that follows `label` in the comment: `"Depth: 2.54%"` → `2.54`. +/// +/// Written by hand rather than with a regex: the crate carries no regex dependency, and the +/// shape is fixed — a label, spaces, a number, a per-cent sign. +fn percent_after(text: &str, label: &str) -> Option { + let rest = text.split_once(label)?.1.trim_start(); + let end = rest + .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+')) + .unwrap_or(rest.len()); + let (num, tail) = rest.split_at(end); + // The per-cent sign is what tells a level from a price: `InitialPrice: 0.0076` must never + // be read as a per cent, and `Depth: 2.54%` must. + tail.starts_with('%').then(|| num.parse::().ok())? +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/hook/tests.rs b/crates/moon-core/src/db/tuner/ticks/hook/tests.rs new file mode 100644 index 00000000..84913186 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/hook/tests.rs @@ -0,0 +1,51 @@ +use super::*; + +/// A real comment, as the core wrote it (GateF, 22.09.2026). +const REAL: &str = " Hook Long Depth: 2.54% [2.54%] R: 62% d: 2.52% (High: 0.007838 Min: 0.007644 Max: 0.007765 [AbsHigh: 0.007870 Drop: 16.09%] VolK: 27.52) InitialPrice: 0.007644 Buffer: [1.25%..1.87%] SellPrice: 1.27% AbsT: 8.3, NextP: 0.00000000\n CPU: Bot 17 (Avg: 10) Sys: 25 AppLatency: 0.0 sec "; + +#[test] +fn reads_the_depth_and_the_placed_take() { + let d = parse_hook_detect(REAL).expect("hook detect"); + assert_eq!(d.depth_pct, 2.54); + assert_eq!(d.stated_take_pct, Some(1.27)); + // The formula reproduces what the core placed, at HookSellLevel = 50. + assert!((hook_take_pct(d.depth_pct, 50.0) - 1.27).abs() <= 0.01); +} + +#[test] +fn a_comment_without_a_detect_is_not_a_hook() { + assert_eq!( + parse_hook_detect("MoonShot: (strategy )\n CPU: Bot 5"), + None + ); + assert_eq!(parse_hook_detect(""), None); +} + +/// The per-cent sign is the guard: a price that follows a label must never read as a level. +#[test] +fn a_price_is_not_a_per_cent() { + assert_eq!( + parse_hook_detect("Hook Long Depth: 0.0076 InitialPrice: 0.0076"), + None + ); + let d = parse_hook_detect("Depth: 4.0% InitialPrice: 0.5").expect("depth"); + assert_eq!(d.depth_pct, 4.0); + assert_eq!(d.stated_take_pct, None, "no SellPrice in this comment"); +} + +/// A zero or broken depth is no depth: the formula would put the take on the fill itself. +#[test] +fn a_zero_depth_is_rejected() { + assert_eq!( + parse_hook_detect("Hook Short Depth: 0% SellPrice: 1%"), + None + ); + assert_eq!(parse_hook_detect("Hook Short Depth: %"), None); +} + +#[test] +fn the_take_scales_with_the_level() { + assert_eq!(hook_take_pct(4.0, 100.0), 4.0, "the top of the move"); + assert_eq!(hook_take_pct(4.0, 50.0), 2.0); + assert_eq!(hook_take_pct(4.0, 0.0), 0.0); +} diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs index 281f9aac..6ddf042a 100644 --- a/crates/moon-core/src/db/tuner/ticks/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/line.rs @@ -39,7 +39,7 @@ //! `PriceDownAllowedDrop` floor computed to 0.196445, the core's order stood at 0.1964, and //! the tape's high was exactly 0.1964 — the unrounded line was never reached. -use super::exit::ExitParams; +use super::exit::{ExitParams, stop_pct}; use super::mshot::FAST_ALGO_WINDOW_MS; use super::{Deal, Exit, ExitKind, Fill, reaches, round_to_step}; use crate::feed::types::Tick; @@ -229,8 +229,15 @@ pub fn walk_held( let mut ss_breach: Option<(bool, i64)> = None; // --- StopLoss --- - let stop_on = params.stop_loss_pct != 0.0; - let stop_level = side.over(fill.price, params.stop_loss_pct); + // The ADJUSTED distance decides both whether there is a stop and where it stands — + // reading the raw `stop_loss_pct` for the first and the adjusted one for the second would + // arm a stop the adjustment had cancelled, at the fill price itself, where the next print + // fires it. `over` mirrors the sign for a short, so only the distance is adjusted here; the + // level is NOT snapped to the price grid, unlike every level that reaches the exchange — + // a stop is the core's own trigger for a market sell, and nothing about it is ever placed. + let stop = stop_pct(params, deal); + let stop_on = stop != 0.0; + let stop_level = side.over(fill.price, stop); let stop_from = fill.t_ms + (params.stop_loss_delay_s.max(0.0) * 1000.0) as i64; let mut last_t = fill.t_ms; diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index bbac45dc..033ef2ef 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -39,6 +39,8 @@ fn deal(short: bool) -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + hook_depth_pct: None, + hook_stated_take_pct: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 48763984..4aaa4a79 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -28,6 +28,7 @@ use crate::market::trade_replay::Coverage; pub mod deals; pub mod entry; pub mod exit; +pub mod hook; pub mod line; pub mod mshot; pub mod params; @@ -39,6 +40,7 @@ pub mod verify; pub use deals::{DealsRead, read_deals}; pub use entry::{EntryModel, entry_model_for}; pub use exit::{ExitModel, ExitParams, archived_pre_spike_ask, archived_take, take_model_for}; +pub use hook::{HookDetect, KIND_MOONHOOK, hook_take_pct, parse_hook_detect}; pub use mshot::{MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; pub use scope::{is_service_row, is_tunable}; @@ -133,6 +135,9 @@ pub struct Deltas { pub btc1h: f64, /// BTC 5-minute delta (`btc5mdelta`). pub btc5m: f64, + /// BTC 1-minute delta (`dbtc1m`) — read by the sell side's `AddBTC1mDelta`; MoonShot's + /// corridor family has no term for it. + pub btc1m: f64, /// Exchange-wide 1-hour delta (`exchange1hdelta`). pub market1h: f64, } @@ -197,6 +202,17 @@ pub struct Deal { /// starts. FLOCK 2026-09-21 (HookN0, short): the model's `SellPrice` take at −1.0 %, the /// core's at −2.2 %, every PriceDown step then a different level. pub archived_take: Option, + /// The detect depth of a MoonHook trade, per cent, as the core wrote it into the report's + /// `comment` — the base of that kind's take rule ([`hook::hook_take_pct`]). `None` for + /// every other kind, and for a hook row whose comment the scan could not read. + pub hook_depth_pct: Option, + /// The take the core actually placed on a hook trade, per cent from the buy, as the same + /// comment states it. Never an input of the model, and nothing asserts it: the ignored + /// `tests::real_data` harness prints it beside the formula's own number so a developer can + /// see the two drift apart on real trades. It could not stand in for the formula anyway — + /// a variant asks about a level the core never used, and this number answers only for the + /// one it did. + pub hook_stated_take_pct: Option, } impl Deal { diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index 79603127..5ea57f2f 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -86,6 +86,9 @@ pub struct Modifiers { pub add_pricebug: f64, pub add_btc_1h: f64, pub add_btc_5m: f64, + /// `AddBTC1mDelta` of the Delta Modifiers tab; the `MShotAdd*` family has no such field + /// and leaves it at zero. + pub add_btc_1m: f64, pub add_market_1h: f64, /// `MShotAddDistance` — per cent by which the far bound's addition exceeds the near one's. pub distance_pct: f64, @@ -105,6 +108,7 @@ impl Modifiers { + self.add_mark * d.dmark + self.add_btc_1h * d.btc1h + self.add_btc_5m * d.btc5m + + self.add_btc_1m * d.btc1m + self.add_market_1h * d.market1h + self.add_pricebug * d.pricebug } diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 0faecbfd..b63ae84c 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -43,9 +43,15 @@ pub struct TickParam { pub kind: ParamKind, /// Strategy kinds whose grid shows this parameter; empty means every kind. pub kinds: &'static [&'static str], + /// Strategy kinds the parameter is HIDDEN from, whatever `kinds` says — a field the core + /// has but this kind's model does not read, so varying it would move no column. `SellPrice` + /// against a MoonHook is the case it exists for: the field is there, and the hook's take + /// comes from `HookSellLevel` instead. + pub not_kinds: &'static [&'static str], } const MSHOT: &[&str] = &["MoonShot"]; +const HOOK: &[&str] = &[super::hook::KIND_MOONHOOK]; const ANY: &[&str] = &[]; const GRID_PRICE: &[f64] = &[ @@ -65,16 +71,25 @@ const GRID_ADD: &[f64] = &[ 0.0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.12, 0.14, 0.16, 0.18, 0.2, ]; const GRID_DISTANCE: &[f64] = &[0.0, 25.0, 50.0, 100.0, 200.0]; +/// Measured against the 1 713 live strategies that set it (2026-09-22): median 1 %, and 300 of +/// them sit outside 0.2…5 — up to 11 % — so the tail is covered rather than clipped. const GRID_SELL_PRICE: &[f64] = &[ 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, - 5.0, + 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, ]; const GRID_SELL_DELAY_MS: &[f64] = &[0.0, 100.0, 250.0, 500.0, 1000.0]; +/// `HookSellLevel`, per cent of the detect depth: 100 sells at the top the move started from, +/// 50 in the middle. The live strategies on this machine use 50 and 100. +const GRID_HOOK_LEVEL: &[f64] = &[ + 10.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 75.0, 80.0, 90.0, 100.0, +]; const GRID_PD_TIMER_S: &[f64] = &[ 0.0, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0, ]; +/// 1 865 live strategies set it; 121 of them below 5 %, which the old floor cut off. const GRID_PD_PCT: &[f64] = &[ - 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 70.0, 80.0, 90.0, 100.0, + 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 70.0, + 80.0, 90.0, 100.0, ]; const GRID_PD_DELAY_S: &[f64] = &[0.0, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 30.0, 60.0]; const GRID_DROP: &[f64] = &[ @@ -92,8 +107,10 @@ const GRID_SS_WAIT_S: &[f64] = &[0.0, 0.1, 0.2, 0.5, 1.0, 2.0]; const GRID_SS_BOUND: &[f64] = &[ -1.0, -0.5, -0.2, -0.1, 0.0, 0.2, 0.4, 0.5, 1.0, 2.0, 5.0, 10.0, ]; +/// Live values run to −15 (29 of 1 869 strategies sit outside the old −10 floor). const GRID_STOP: &[f64] = &[ - -10.0, -7.0, -5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.75, -0.5, -0.3, -0.2, -0.1, + -15.0, -12.0, -10.0, -7.0, -5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.75, -0.5, -0.3, -0.2, + -0.1, ]; const GRID_STOP_DELAY_S: &[f64] = &[0.0, 1.0, 2.0, 4.0, 6.0, 10.0, 20.0, 30.0]; @@ -104,6 +121,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_PRICE }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotPriceMin", @@ -112,102 +130,119 @@ pub const TICK_PARAMS: &[TickParam] = &[ grid: GRID_PRICE_MIN, }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotUsePrice", group: ParamGroup::Entry, kind: ParamKind::Enum(&["Trade", "ASK", "BID"]), kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotRaiseWait", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_WAIT_S }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotReplaceDelay", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_WAIT_S }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotMinusSatoshi", group: ParamGroup::Entry, kind: ParamKind::Bool, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "FastShotAlgo", group: ParamGroup::Entry, kind: ParamKind::Bool, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAddHourlyDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAdd3hDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAdd15minDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAdd5minDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAdd1minDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAdd24hDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAddMarkDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAddMarketDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAddBTCDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAddBTC5mDelta", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAddPriceBug", group: ParamGroup::Entry, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotAddDistance", @@ -216,6 +251,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ grid: GRID_DISTANCE, }, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "SellPrice", @@ -224,18 +260,31 @@ pub const TICK_PARAMS: &[TickParam] = &[ grid: GRID_SELL_PRICE, }, kinds: ANY, + // A MoonHook carries no `SellPrice` at all — `HookSellLevel` below is its take. + not_kinds: HOOK, }, TickParam { key: "MShotSellAtLastPrice", group: ParamGroup::Exit, kind: ParamKind::Bool, kinds: MSHOT, + not_kinds: &[], }, TickParam { key: "MShotSellPriceAdjust", group: ParamGroup::Exit, kind: ParamKind::Num { grid: GRID_ADJUST }, kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "HookSellLevel", + group: ParamGroup::Exit, + kind: ParamKind::Num { + grid: GRID_HOOK_LEVEL, + }, + kinds: HOOK, + not_kinds: &[], }, TickParam { key: "SellDelay", @@ -244,6 +293,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ grid: GRID_SELL_DELAY_MS, }, kinds: ANY, + not_kinds: &[], }, exit_num("PriceDownTimer", GRID_PD_TIMER_S), exit_num("PriceDownPercent", GRID_PD_PCT), @@ -278,6 +328,7 @@ const fn exit_num(key: &'static str, grid: &'static [f64]) -> TickParam { group: ParamGroup::Exit, kind: ParamKind::Num { grid }, kinds: ANY, + not_kinds: &[], } } @@ -288,6 +339,7 @@ const fn exit_bool(key: &'static str) -> TickParam { group: ParamGroup::Exit, kind: ParamKind::Bool, kinds: ANY, + not_kinds: &[], } } @@ -296,14 +348,57 @@ pub fn params_for<'k>( group: ParamGroup, kind: &'k str, ) -> impl Iterator + 'k { - TICK_PARAMS - .iter() - .filter(move |p| p.group == group && (p.kinds.is_empty() || p.kinds.contains(&kind))) + TICK_PARAMS.iter().filter(move |p| { + p.group == group + && (p.kinds.is_empty() || p.kinds.contains(&kind)) + && !p.not_kinds.contains(&kind) + }) } -/// The field names of [`TICK_PARAMS`], for a `strategy_current_values` read. +/// Strategy fields the models READ but the grid does not offer as knobs. +/// +/// They still have to be fetched: `param_keys` is what a `strategy_values_at` read asks for, so +/// a field missing from this list reads as absent and the builder silently takes its fallback — +/// which is how the delta modifiers went unapplied through a whole measurement run on 2026-09-22 +/// while every test passed. +/// +/// `HookSellFixed` is here because it is not a knob (the branch it selects is not modelled) but +/// its value decides whether the take is known at all; the `Add*` family and its two +/// coefficients are here because they move the level of every kind, and none of them is +/// something the search should turn. +const MODEL_ONLY_KEYS: &[&str] = &[ + "HookSellFixed", + // Read by `exit_params` and acted on by the SellShot walk, never a grid knob — and absent + // from both lists until 2026-09-22, so the decay of the sell-shot distance has been running + // on its fallback since the axis was written. + "SellShotPriceDown", + "SellShotPriceDownDelay", + "SellModifier", + "MaxModifier", + "StopLossModifier", + "Add1minDelta", + "Add5minDelta", + "Add15minDelta", + "AddHourlyDelta", + "Add3hDelta", + "Add24hDelta", + "AddMarkDelta", + "AddPriceBug", + "AddBTCDelta", + "AddBTC1mDelta", + "AddBTC5mDelta", + "AddMarketDelta", +]; + +/// Every field name the models read — [`TICK_PARAMS`] plus [`MODEL_ONLY_KEYS`] — for a +/// `strategy_current_values` read. pub fn param_keys() -> Vec { - TICK_PARAMS.iter().map(|p| p.key.to_string()).collect() + TICK_PARAMS + .iter() + .map(|p| p.key) + .chain(MODEL_ONLY_KEYS.iter().copied()) + .map(str::to_string) + .collect() } /// Strategy values as `strategy_current_values` hands them (strings, `YES`/`NO` booleans) plus @@ -374,6 +469,8 @@ pub fn mshot_params(v: &StrategyValues<'_>, latency_ms: f64) -> MshotParams { add_mark: v.num("MShotAddMarkDelta", 0.0), add_pricebug: v.num("MShotAddPriceBug", 0.0), add_btc_1h: v.num("MShotAddBTCDelta", 0.0), + // MoonShot's corridor family has no 1-minute BTC term; the Delta Modifiers tab does. + add_btc_1m: 0.0, add_btc_5m: v.num("MShotAddBTC5mDelta", 0.0), add_market_1h: v.num("MShotAddMarketDelta", 0.0), distance_pct: v.num("MShotAddDistance", 0.0), @@ -390,6 +487,29 @@ pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { sell_at_last_price: v.bool("MShotSellAtLastPrice", base.sell_at_last_price), sell_price_adjust_pct: v.num("MShotSellPriceAdjust", base.sell_price_adjust_pct), sell_delay_ms: v.num("SellDelay", base.sell_delay_ms), + hook_sell_level_pct: v.num("HookSellLevel", base.hook_sell_level_pct), + hook_sell_fixed: v.bool("HookSellFixed", base.hook_sell_fixed), + sell_modifier: v.num("SellModifier", base.sell_modifier), + max_modifier: v.num("MaxModifier", base.max_modifier), + stop_loss_modifier: v.num("StopLossModifier", base.stop_loss_modifier), + // The Delta Modifiers tab's own family — not `MShotAdd*`, which moves the entry + // corridor; a strategy can carry both, and reading one for the other would move the + // sell by the buy's coefficients. + sell_mods: Modifiers { + add_1m: v.num("Add1minDelta", 0.0), + add_5m: v.num("Add5minDelta", 0.0), + add_15m: v.num("Add15minDelta", 0.0), + add_1h: v.num("AddHourlyDelta", 0.0), + add_3h: v.num("Add3hDelta", 0.0), + add_24h: v.num("Add24hDelta", 0.0), + add_mark: v.num("AddMarkDelta", 0.0), + add_pricebug: v.num("AddPriceBug", 0.0), + add_btc_1h: v.num("AddBTCDelta", 0.0), + add_btc_1m: v.num("AddBTC1mDelta", 0.0), + add_btc_5m: v.num("AddBTC5mDelta", 0.0), + add_market_1h: v.num("AddMarketDelta", 0.0), + distance_pct: 0.0, + }, price_down_timer_s: v.num("PriceDownTimer", base.price_down_timer_s), price_down_pct: v.num("PriceDownPercent", base.price_down_pct), price_down_delay_s: v.num("PriceDownDelay", base.price_down_delay_s), diff --git a/crates/moon-core/src/db/tuner/ticks/scope.rs b/crates/moon-core/src/db/tuner/ticks/scope.rs index 27eba392..e2f79a17 100644 --- a/crates/moon-core/src/db/tuner/ticks/scope.rs +++ b/crates/moon-core/src/db/tuner/ticks/scope.rs @@ -23,6 +23,33 @@ pub const SERVICE_SELL_REASONS: [&str; 3] = ["Funding", "LIQUIDATION", "JoinedSe /// Strategy kinds (`SignalType`) that hold no trading rule of their own. const CONTAINER_KINDS: [&str; 3] = ["Manual", "Alerts", "Watcher"]; +/// How much more than it bought a row may sell and still be one position: the fraction the +/// exchange's own rounding of a lot can add. Anything past it is coins the core topped the sale +/// up with from the wallet balance. +const SOLD_OVER_BOUGHT_EPS: f64 = 1e-6; + +/// Whether the row sold MORE coins than it bought. +/// +/// On spot, a position that comes out under the exchange's minimum lot is topped up from the +/// wallet balance, and the sale then covers coins this trade never bought: its `sellprice` is an +/// average over a different amount, and the level the model is judged against is not the level +/// the rule placed. Such a row is excluded like a manual sell — the tape cannot explain it. +/// +/// Measured on this machine's replica (2026-09-22): 1 row of 606 767 by this signature, so the +/// exclusion costs nothing here and matters on a spot core that does it often. The opposite +/// direction — selling slightly LESS — is ordinary: the fee is taken in coin, and 2 242 rows sit +/// a fraction below their bought amount. +/// +/// Args: +/// quantity: The row's `quantity` — what the sale moved. +/// bought: The row's `boughtq` — what the entry filled. +pub fn sold_more_than_bought(quantity: f64, bought: f64) -> bool { + quantity.is_finite() + && bought.is_finite() + && bought > 0.0 + && quantity > bought * (1.0 + SOLD_OVER_BOUGHT_EPS) +} + /// Whether a report row is a service row — never a deal of the axis. /// /// Args: diff --git a/crates/moon-core/src/db/tuner/ticks/scope/tests.rs b/crates/moon-core/src/db/tuner/ticks/scope/tests.rs index 26fada1a..27a15364 100644 --- a/crates/moon-core/src/db/tuner/ticks/scope/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/scope/tests.rs @@ -9,6 +9,22 @@ fn service_rows_are_the_no_strategy_and_the_named_reasons() { assert!(is_service_row(42, "Funding")); assert!(is_service_row(42, "LIQUIDATION")); assert!(is_service_row(42, "JoinedSell")); +} + +/// A spot sale topped up from the wallet balance moved coins the entry never bought, so its +/// price is an average of something else. +#[test] +fn a_sale_bigger_than_its_entry_is_not_a_deal() { + assert!(sold_more_than_bought(101.0, 100.0)); + assert!(!sold_more_than_bought(100.0, 100.0), "the ordinary case"); + // The fee is taken in coin on spot, so selling slightly LESS is normal. + assert!(!sold_more_than_bought(99.88, 100.0)); + // Nothing to compare against is not a finding. + assert!(!sold_more_than_bought(101.0, 0.0)); + assert!(!sold_more_than_bought(f64::NAN, 100.0)); + assert!(!sold_more_than_bought(101.0, f64::INFINITY)); + // Float noise on a lot must not read as a top-up. + assert!(!sold_more_than_bought(100.0 + 1e-9, 100.0)); assert!(!is_service_row(42, "Auto Price Down")); assert!(!is_service_row(42, "Sell Price")); assert!(!is_service_row(42, "StopLoss Market Sell")); diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index a5d8c1b2..aaf2d439 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -187,6 +187,10 @@ fn varied<'a>(p: &SearchParams<'a>) -> Vec<&'static super::params::TickParam> { ParamGroup::Exit => p.vary_exit, }) .filter(|f| f.kinds.is_empty() || f.kinds.contains(&p.kind)) + // The same gate the grid applies: a field this kind's model does not read would be + // varied for nothing, land in a variant column the grid cannot show, and be written by + // Save all the same. + .filter(|f| !f.not_kinds.contains(&p.kind)) .filter(|f| !p.locked.contains(f.key)) .collect() } diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 84b5d6c2..5a26ecdc 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -39,6 +39,8 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { tick: None, pre_spike_ask: None, archived_take: None, + hook_depth_pct: None, + hook_stated_take_pct: None, }; let t0 = deal.buy_ms; let ticks: Vec = vec![ diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index 0b000753..ac0da2d8 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -22,6 +22,8 @@ fn deal(pnl: f64, spent: f64) -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + hook_depth_pct: None, + hook_stated_take_pct: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 3194d635..580c826b 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use super::exit::pre_spike_price; +use super::exit::stop_pct as moon_core_stop_pct; use super::mshot::{DEFAULT_LATENCY_MS, Modifiers, PRE_SPIKE_LOOKBACK_MS}; use super::params::{StrategyValues, exit_params, mshot_params, param_keys, params_for}; use super::verify::share; @@ -48,6 +49,8 @@ fn deal() -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + hook_depth_pct: None, + hook_stated_take_pct: None, } } @@ -1003,12 +1006,37 @@ fn the_descriptor_keys_every_field_the_builders_read_and_splits_the_groups() { "MShotSellAtLastPrice", "MShotSellPriceAdjust", "SellDelay", + // Read by the builders, not shown in the grid — and just as fatal when unfetched: an + // absent key reads as the model's fallback, silently. + "HookSellLevel", + "HookSellFixed", + "SellModifier", + "MaxModifier", + "Add5minDelta", + "AddHourlyDelta", + "AddBTC1mDelta", ] { assert!( keys.iter().any(|k| k == key), - "{key} missing from TICK_PARAMS" + "{key} missing from the keys the models read" ); } + // The allowlist above cannot see a field that is missing from BOTH lists, which is exactly + // how `SellShotPriceDown`/`SellShotPriceDownDelay` ran on their fallback from the day the + // axis was written. So the builders' own source is the authority: every strategy field + // `mshot_params`/`exit_params` reads must be a key the load asks the database for. + let source = include_str!("params.rs"); + for call in [".num(\"", ".bool(\"", ".text(\""] { + let mut rest = source; + while let Some(at) = rest.find(call) { + rest = &rest[at + call.len()..]; + let key = &rest[..rest.find('"').expect("a closing quote")]; + assert!( + keys.iter().any(|k| k == key), + "{key} is read by a builder but never fetched: add it to TICK_PARAMS or to MODEL_ONLY_KEYS, or it silently reads as the model's fallback" + ); + } + } assert!(params_for(ParamGroup::Entry, "Spread").next().is_none()); assert!(params_for(ParamGroup::Entry, "MoonShot").count() > 10); let exit_any: Vec<_> = params_for(ParamGroup::Exit, "Spread") @@ -1033,3 +1061,424 @@ fn infer_tick_reads_the_grid_and_snaps_float_noise() { assert_eq!(infer_tick(&tape(&[(0, 1.0), (1, 1.0)])), None); assert_eq!(infer_tick(&[]), None); } + +// ---- MoonHook: the take is a share of the detect depth, not `SellPrice` ------------------- + +/// A hook deal: 4 % detect depth, bought at 100, the core's own take stated at 2 %. +fn hook_deal() -> Deal { + Deal { + kind: KIND_MOONHOOK.into(), + buy_price: 100.0, + hook_depth_pct: Some(4.0), + hook_stated_take_pct: Some(2.0), + ..deal() + } +} + +/// `HookSellLevel` = 50 of a 4 % depth is a 2 % take — and `SellPrice` is not consulted at all. +#[test] +fn a_hook_takes_a_share_of_its_detect_depth() { + let params = ExitParams { + sell_price_pct: 1.0, + hook_sell_level_pct: 50.0, + ..ExitParams::default() + }; + let fill = Fill { + t_ms: 10_000, + price: 100.0, + }; + let take = ExitModel::new(¶ms).take_level(&hook_deal(), &[], fill); + assert!((take - 102.0).abs() < 1e-9, "{take}"); + // The level scales with the parameter — that is what makes it searchable. + let doubled = ExitParams { + hook_sell_level_pct: 100.0, + ..params.clone() + }; + let take = ExitModel::new(&doubled).take_level(&hook_deal(), &[], fill); + assert!((take - 104.0).abs() < 1e-9, "{take}"); +} + +/// A short hook sells below the entry, by the same share. +#[test] +fn a_short_hook_takes_below_the_entry() { + let params = ExitParams { + hook_sell_level_pct: 50.0, + ..ExitParams::default() + }; + let d = Deal { + is_short: true, + ..hook_deal() + }; + let fill = Fill { + t_ms: 10_000, + price: 100.0, + }; + let take = ExitModel::new(¶ms).take_level(&d, &[], fill); + assert!((take - 98.0).abs() < 1e-9, "{take}"); +} + +/// Without a depth (or without a level) the rule cannot be computed — the model still needs a +/// line to walk, so it falls back, but it must SAY that it does not know. +#[test] +fn a_hook_without_its_depth_is_not_a_known_take() { + let params = ExitParams { + hook_sell_level_pct: 50.0, + ..ExitParams::default() + }; + let model = ExitModel::new(¶ms); + assert!(model.take_known(&hook_deal())); + let no_depth = Deal { + hook_depth_pct: None, + ..hook_deal() + }; + assert!(!model.take_known(&no_depth)); + let no_level = ExitParams { + hook_sell_level_pct: 0.0, + ..params.clone() + }; + assert!(!ExitModel::new(&no_level).take_known(&hook_deal())); + // `HookSellFixed` is the other branch of the rule, and it is not modelled. + let fixed = ExitParams { + hook_sell_fixed: true, + ..params.clone() + }; + assert!(!ExitModel::new(&fixed).take_known(&hook_deal())); + // Every other kind takes by `SellPrice`, which the model has — with or without an archive. + assert!(model.take_known(&deal()), "MoonShot"); + for kind in ["Spread", "PumpsDetection", "Combo"] { + let d = Deal { + kind: kind.into(), + ..deal() + }; + assert!(model.take_known(&d), "{kind} takes by SellPrice"); + } + // An archived level answers for a hook the formula cannot reach. + assert!(model.take_known(&Deal { + archived_take: Some(101.0), + ..no_depth + })); +} + +/// A modifier deep enough to drive the distance negative must not put the take on the losing +/// side of the entry — the line steps DOWN from the take, and a take below the fill inverts it. +#[test] +fn a_negative_modifier_cannot_push_the_take_through_the_fill() { + let mut mods = Modifiers::default(); + mods.add_1h = 1.0; + let params = ExitParams { + sell_price_pct: 1.0, + sell_modifier: 1.0, + sell_mods: mods, + ..ExitParams::default() + }; + let d = Deal { + deltas: Deltas { + d1h: -50.0, + ..Deltas::default() + }, + ..deal() + }; + let fill = Fill { + t_ms: 10_000, + price: 100.0, + }; + let take = ExitModel::new(¶ms).take_level(&d, &[], fill); + assert!( + (take - 100.0).abs() < 1e-9, + "floored at the fill, got {take}" + ); +} + +/// The grid must not offer a knob that moves nothing: `SellPrice` is not a MoonHook's take. +#[test] +fn the_grid_hides_sell_price_from_a_hook_and_offers_its_own_level() { + let hook: Vec<&str> = params_for(ParamGroup::Exit, KIND_MOONHOOK) + .map(|p| p.key) + .collect(); + assert!(!hook.contains(&"SellPrice"), "the hook has no such field"); + assert!(hook.contains(&"HookSellLevel")); + assert!( + !hook.contains(&"HookSellFixed"), + "read, but not modelled — so not a knob" + ); + let spread: Vec<&str> = params_for(ParamGroup::Exit, "Spread") + .map(|p| p.key) + .collect(); + assert!(spread.contains(&"SellPrice")); + assert!(!spread.contains(&"HookSellLevel"), "a hook-only field"); +} + +/// The verdict on a take it cannot place is nothing, not a miss — the whole point of the +/// exercise: data we hold must not be filed as "the model was wrong". +#[test] +fn an_unknown_take_leaves_the_exit_unanswered() { + let ticks = tape(&[(10_000, 100.0), (15_000, 101.0), (20_000, 102.0)]); + let params = ExitParams { + hook_sell_level_pct: 50.0, + take_from_archive: true, + ..ExitParams::default() + }; + let known = verify( + &hook_deal(), + &ticks, + &EntryParams::Fact, + ¶ms, + None, + None, + ); + assert!( + known.exit.is_some(), + "a depth is a level the model can place" + ); + let blind = Deal { + hook_depth_pct: None, + ..hook_deal() + }; + let v = verify(&blind, &ticks, &EntryParams::Fact, ¶ms, None, None); + assert_eq!(v.exit, None, "no level, no verdict"); + assert_eq!(v.exit_dev_pct, None); + // A stop is judged all the same: it fires off `StopLoss`, not off the take. + let stopped = Deal { + sell_reason: "StopLoss Market Sell".into(), + sell_price: 97.0, + ..blind + }; + let stop_params = ExitParams { + stop_loss_pct: -2.0, + ..params.clone() + }; + let down = tape(&[(10_000, 100.0), (15_000, 97.9), (20_000, 97.0)]); + let v = verify( + &stopped, + &down, + &EntryParams::Fact, + &stop_params, + None, + None, + ); + assert!(v.exit.is_some(), "the stop does not depend on the take"); +} + +// ---- the stop and its modifier ------------------------------------------------------------- + +/// The core's own log line, verbatim: `StopLoss adjusted [-2.00% - (0.20*1.86=0.37%) => -2.37%]`. +/// 136 such lines were read off this machine's cores and every one obeys this arithmetic. +#[test] +fn the_stop_modifier_deepens_the_stop_by_the_summed_deltas() { + let mut mods = Modifiers::default(); + mods.add_1h = 1.0; + let params = ExitParams { + stop_loss_pct: -2.0, + stop_loss_modifier: 0.2, + sell_mods: mods, + ..ExitParams::default() + }; + let d = Deal { + deltas: Deltas { + d1h: 1.86, + ..Deltas::default() + }, + ..deal() + }; + let pct = moon_core_stop_pct(¶ms, &d); + assert!((pct - -2.372).abs() < 1e-9, "{pct}"); + // No coefficient, no movement; no stop, nothing to move. + let off = ExitParams { + stop_loss_modifier: 0.0, + ..params.clone() + }; + assert_eq!(moon_core_stop_pct(&off, &d), -2.0); + let no_stop = ExitParams { + stop_loss_pct: 0.0, + ..params.clone() + }; + assert_eq!(moon_core_stop_pct(&no_stop, &d), 0.0); + // `MaxModifier` caps the sum before the coefficient, as it does for the sell. + let capped = ExitParams { + max_modifier: 1.0, + ..params + }; + assert!((moon_core_stop_pct(&capped, &d) - -2.2).abs() < 1e-9); +} + +/// The adjustment may pull the stop toward the entry — live strategies carry a negative +/// `StopLossModifier` — but one that pulls it THROUGH the entry leaves no stop at all, rather +/// than one a hair from the entry that the next print would trip. +#[test] +fn an_adjustment_through_the_entry_leaves_no_stop() { + let mut mods = Modifiers::default(); + mods.add_1h = 1.0; + let base = ExitParams { + stop_loss_pct: -2.0, + stop_loss_modifier: -0.3, + sell_mods: mods, + ..ExitParams::default() + }; + let far = Deal { + deltas: Deltas { + d1h: 70.0, + ..Deltas::default() + }, + ..deal() + }; + // −2 − 70·(−0.3) = +19 unguarded: a "stop" nineteen per cent in profit. + assert_eq!( + moon_core_stop_pct(&base, &far), + 0.0, + "no stop, not a near one" + ); + // A negative delta sum with a positive coefficient reaches the same place from the other + // side. + let other = ExitParams { + stop_loss_modifier: 0.3, + ..base.clone() + }; + let down = Deal { + deltas: Deltas { + d1h: -70.0, + ..Deltas::default() + }, + ..deal() + }; + assert_eq!(moon_core_stop_pct(&other, &down), 0.0); + // A modifier that only moves the stop within its own side is applied as it is. + let mild = Deal { + deltas: Deltas { + d1h: 2.0, + ..Deltas::default() + }, + ..deal() + }; + assert!((moon_core_stop_pct(&base, &mild) - -1.4).abs() < 1e-9); + // A stop the strategy itself put on the profit side stays where it put it — that is its own + // setting, not something the adjustment did. + let positive = ExitParams { + stop_loss_pct: 1.0, + stop_loss_modifier: 0.3, + ..base.clone() + }; + let up = Deal { + deltas: Deltas { + d1h: 2.0, + ..Deltas::default() + }, + ..deal() + }; + assert!((moon_core_stop_pct(&positive, &up) - 0.4).abs() < 1e-9); +} + +/// An adjustment that exactly cancels the stop must not leave one armed at the fill price, +/// where the next print fires it. +#[test] +fn a_cancelled_stop_does_not_fire_at_the_entry() { + let mut mods = Modifiers::default(); + mods.add_1h = 1.0; + let params = ExitParams { + stop_loss_pct: -2.0, + stop_loss_modifier: 0.2, + sell_price_pct: 5.0, + sell_mods: mods, + ..ExitParams::default() + }; + // Σ = −10, so −2 − (−10·0.2) = 0 exactly without the clamp. + let d = Deal { + deltas: Deltas { + d1h: -10.0, + ..Deltas::default() + }, + ..deal() + }; + // The adjustment cancels the stop exactly, so there is none — and the print below the + // entry must not read as one. + assert_eq!(moon_core_stop_pct(¶ms, &d), 0.0); + let walk = ExitModel::new(¶ms).walk( + &d, + // A real move down, not float noise: without the guard the stop sits ON the entry and + // this print trips it. + &tape(&[(10_000, 100.0), (11_000, 99.99), (12_000, 100.02)]), + Fill { + t_ms: 10_000, + price: 100.0, + }, + ); + assert_ne!( + walk.exit.kind, + ExitKind::Stop, + "a print a hundredth of a per cent away is not a stop: {:?}", + walk.exit + ); +} + +/// A short's stop sits ABOVE the entry, and the same distance mirrors there. +#[test] +fn a_short_stop_mirrors_with_the_modifier() { + let mut mods = Modifiers::default(); + mods.add_1h = 1.0; + let params = ExitParams { + stop_loss_pct: -2.0, + stop_loss_modifier: 0.2, + sell_mods: mods, + ..ExitParams::default() + }; + let d = Deal { + deltas: Deltas { + d1h: 5.0, + ..Deltas::default() + }, + ..short_deal() + }; + // −2 − 0.2·5 = −3 per cent, and a short's stop is that far ABOVE the fill. + let walk = ExitModel::new(¶ms).walk( + &d, + &tape(&[(10_000, 100.0), (15_000, 103.5)]), + Fill { + t_ms: 10_000, + price: 100.0, + }, + ); + assert_eq!(walk.exit.kind, ExitKind::Stop, "the price crossed 103"); + assert!((walk.exit.price - 103.5).abs() < 1e-9, "{:?}", walk.exit); +} + +// ---- the sell-side delta modifiers --------------------------------------------------------- + +/// FAQ: a summed delta of 5 % with `SellModifier = 0.2` places the sell 1 % higher. +#[test] +fn sell_modifiers_lift_the_take_by_the_faq_example() { + let mut mods = Modifiers::default(); + mods.add_1h = 1.0; + let params = ExitParams { + sell_price_pct: 1.0, + sell_modifier: 0.2, + sell_mods: mods, + ..ExitParams::default() + }; + let d = Deal { + deltas: Deltas { + d1h: 5.0, + ..Deltas::default() + }, + ..deal() + }; + let fill = Fill { + t_ms: 10_000, + price: 100.0, + }; + // 1 % of SellPrice plus 5 % * 0.2 = 2 % in all. + let take = ExitModel::new(¶ms).take_level(&d, &[], fill); + assert!((take - 102.0).abs() < 1e-9, "{take}"); + // `MaxModifier` caps the SUM before the coefficient: min(2, 5) * 0.2 = 0.4. + let capped = ExitParams { + max_modifier: 2.0, + ..params.clone() + }; + let take = ExitModel::new(&capped).take_level(&d, &[], fill); + assert!((take - 101.4).abs() < 1e-9, "{take}"); + // No coefficient, no movement — whatever the deltas. + let off = ExitParams { + sell_modifier: 0.0, + ..params + }; + let take = ExitModel::new(&off).take_level(&d, &[], fill); + assert!((take - 101.0).abs() < 1e-9, "{take}"); +} diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index d3480ec8..477aa071 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -98,6 +98,9 @@ fn real_data_reproduction() { ..Default::default() }; let read = read_deals(&scope).expect("deals"); + eprintln!( + "NOTE: no core schema here, so `defaults` is empty — a field the strategy dump omits (it is at the core default) reads as the model's own fallback, not as the core's. The application passes the live schema (`strategy_field_defaults`)." + ); eprintln!( "deals with ms stamps: {} · without: {}", read.deals.len(), @@ -208,7 +211,7 @@ fn real_data_reproduction() { eprintln!( "{uid} {coin:<8} {kind:<8} buy {buy:.6} | plain fill {fill:?} dev {dev:?} ✓{ok:?} | \ archived start {start:?} fill {fill2:?} dev {dev2:?} ✓{ok2:?} | \ - exit {exit_kind:?} ✓{exit_ok:?} dev {exit_dev:?} line {line:?} | {reason} | ticks {n} step {tick:?}", + exit {exit_kind:?} ✓{exit_ok:?} dev {exit_dev:?} line {line:?} | {reason} | ticks {n} step {tick:?} | hook depth {hook_depth:?} core {hook_stated:?} model {hook_model:?}", uid = deal.report_uid, coin = deal.coin, kind = deal.kind, @@ -227,6 +230,14 @@ fn real_data_reproduction() { reason = deal.sell_reason, n = ticks.len(), tick = deal.tick, + hook_depth = round3(deal.hook_depth_pct), + hook_stated = round3(deal.hook_stated_take_pct), + // The formula against the core's own number, for the same trade — the check that + // keeps `docs-internal/STRATEGY_FORMULAS/moonhook.md` honest over time. + hook_model = round3( + deal.hook_depth_pct + .map(|d| super::super::hook::hook_take_pct(d, exit.hook_sell_level_pct)) + ), ); let best = if entry_line.is_some() { archived diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 298abae1..64e417d5 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -10,6 +10,14 @@ //! it is not modelled; an exit the core closed by a rule the model does not have is not a miss //! of the rules it does. An exit the model never reached at all IS a miss. //! +//! The same holds for the LEVEL the exit is judged against. `SellPrice` is the take of every +//! kind the core has but one, so this bites on MoonHook alone: with no archived line to read the +//! level off, and no detect depth or `HookSellLevel` to compute it from (or with `HookSellFixed`, +//! whose branch is not modelled), the sell line stands somewhere the model invented, and +//! everything downstream of it — where PriceDown stepped to, whether a level stood at the close — +//! is invented with it. That answers `None`, whatever the deviation says +//! ([`ExitModel::take_known`]). A stop is exempt: it fires off `StopLoss`, not off the take. +//! //! The entry is held to the CORRIDOR, not to a price step: a MoonShot order chasing a falling //! price is re-placed off whichever print left the corridor, and the core's print and the //! model's differ by a second and a fraction of a per cent on every such chase (GSTOCKBSC @@ -176,7 +184,12 @@ pub fn verify( } } }; - let (exit_ok, exit_dev, line_points) = if closed.kind == ExitKind::OpenAtWindowEnd { + // Where the take itself is not modelled for this trade, the line under it is not the + // model's answer but its guess — see the module doc. + let take_known = ExitModel::new(&fact_exit).take_known(deal); + let (exit_ok, exit_dev, line_points) = if !take_known && closed.kind != ExitKind::Stop { + (None, None, None) + } else if closed.kind == ExitKind::OpenAtWindowEnd { // No line stood at the close: a miss of the exit group, not an unanswered question. (Some(false), None, None) } else if exit_rule_matches(closed.kind, &deal.sell_reason) { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index 57ccea55..1e64524e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -3,9 +3,11 @@ //! //! Left, under the strategy list: the deal table — one row per closed trade with millisecond //! stamps, what came of it, whether the terminal holds its tape, and whether the model -//! reproduces the fact; a double-click opens the trade window on it. By default it shows only -//! the rows the tuner counts (tape held, fact reproduced), and its status line says how many -//! that is out of the scope. Right: the shared "Fact vs …" matrix (the whole scope, the +//! reproduces the fact; a double-click opens the trade window on it. Every row of the scope is +//! shown; the switch under the table narrows it to the rows whose tape covers the window — the +//! sample the variants and the search run on — and the status line says how many that is out of +//! the scope. The model's verdict is a COLUMN, never a filter. Right: the shared "Fact vs …" +//! matrix (the whole scope, the //! replayable subset captioned with the ✓ shares, the variant columns), and the parameter grid //! with the strategies' values, the two variant columns and the search row. //! @@ -53,9 +55,9 @@ impl AnalyticsView { let scope = self.scope_label(); // The order is settled before the data is viewed: both live in `ticks`, and the sort // cache needs the mutable half. It is the SHOWN rows: with the switch on, only the - // ones the tuner counts. + // ones whose tape covers the window. let drawn = rows::order_for(&mut self.ticks).len(); - let only_tunable = self.ticks.only_tunable; + let only_with_tape = self.ticks.only_with_tape; // The rows the scope holds but the table does not show, for the caption and for the // empty state: a period entirely before the millisecond stamps is not an empty period, // and the table must say which it is rather than draw the shared "no trades". @@ -71,10 +73,9 @@ impl AnalyticsView { d.covered(), d.fetchable().count(), d.without_ms, - d.tunable(), ) }); - let (body, total, covered, fetchable, without_ms, tunable) = match summary { + let (body, total, covered, fetchable, without_ms) = match summary { Err(crate::load_state::Note::Empty) if left_out != (0, 0, 0) => ( crate::load_state::muted( t!( @@ -92,7 +93,6 @@ impl AnalyticsView { 0usize, 0usize, left_out.0, - 0usize, ), Err(note) => ( super::super::note_el("an-ticks-note", note, 10.0, p, cx), @@ -100,17 +100,16 @@ impl AnalyticsView { 0usize, 0usize, 0usize, - 0usize, ), // Rows loaded, none shown: the switch hid every one. Said in words, with the // count, rather than drawn as a blank list — while the tape stage still reads, - // the verdicts are not in yet and the note says that instead. - Ok((total, covered, fetchable, without_ms, tunable)) if drawn == 0 => ( + // no row is covered yet and the note says that instead. + Ok((total, covered, fetchable, without_ms)) if drawn == 0 => ( crate::load_state::muted( if self.ticks.tape_reading { t!("analytics.ticks.fetch_reading").to_string() } else { - t!("analytics.ticks.none_tunable", hidden = total).to_string() + t!("analytics.ticks.none_with_tape", hidden = total).to_string() }, 10.0, p, @@ -120,9 +119,8 @@ impl AnalyticsView { covered, fetchable, without_ms, - tunable, ), - Ok((total, covered, fetchable, without_ms, tunable)) => { + Ok((total, covered, fetchable, without_ms)) => { let weak = cx.entity().downgrade(); let row_h = deal_row_h(cx); let list = @@ -145,7 +143,7 @@ impl AnalyticsView { .radius(0.0) .scrollbar_visibility(MoonScrollbarVisibility::Hover) .into_any_element(); - (list, total, covered, fetchable, without_ms, tunable) + (list, total, covered, fetchable, without_ms) } }; // The batch is the process's (`fetch::job`), not this window's: the caption reads its @@ -191,32 +189,26 @@ impl AnalyticsView { } else { String::new() }; - // The status line: the honest size of the sample — how many rows the tuner counts, - // how many have their tape, out of how many — with what the scope holds beyond the - // table, and the switch that hides the rest. - let status = { - let mut line = t!("analytics.ticks.tunable_n", n = tunable).to_string(); - line.push_str(" · "); - line.push_str(&coverage_caption( - covered, total, without_ms, left_out.1, left_out.2, - )); - line - }; - let only_tip = t!("analytics.ticks.only_tunable_tip").to_string(); + // The status line: the honest size of the sample — how many rows have their tape, + // out of how many — with what the scope holds beyond the table, and the switch that + // hides the rest. How well the MODEL does on that sample is the KPI caption's ✓ + // shares (`ticks_kpi`), not a size. + let status = coverage_caption(covered, total, without_ms, left_out.1, left_out.2); + let only_tip = t!("analytics.ticks.only_with_tape_tip").to_string(); let only_switch = div() - .id("an-ticks-only-tunable-box") + .id("an-ticks-only-tape-box") .flex_none() .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(only_tip.clone())).into()) .child( - MoonCheckbox::new("an-ticks-only-tunable") - .label(t!("analytics.ticks.only_tunable").to_string()) - .checked(only_tunable) + MoonCheckbox::new("an-ticks-only-tape") + .label(t!("analytics.ticks.only_with_tape").to_string()) + .checked(only_with_tape) .on_change({ let view = cx.entity(); move |on: &bool, _w, app| { let on = *on; view.update(app, |this, cx| { - this.ticks.only_tunable = on; + this.ticks.only_with_tape = on; // The cached order is a permutation of the SHOWN rows. this.ticks.order = None; cx.notify(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs index ef5978f9..e90fcb97 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs @@ -1,4 +1,4 @@ -//! The deal table's row order — a permutation over the loaded rows, filtered by the "tunable +//! The deal table's row order — a permutation over the loaded rows, filtered by the "with tape //! only" switch and cached against the data generation, the sort and the switch, so a repaint //! that changed none of them reuses it instead of sorting hundreds of deals per frame. @@ -9,21 +9,23 @@ use super::state::{DealRow, TapeStatus, TicksState}; pub(in crate::analytics::tuner) struct OrderCache { pub(in crate::analytics::tuner) rows_rev: u64, pub(in crate::analytics::tuner) sort: Option<(String, bool)>, - pub(in crate::analytics::tuner) only_tunable: bool, + pub(in crate::analytics::tuner) only_with_tape: bool, /// Indices into `TicksData::rows` — the shown rows only, when the switch hides the rest. pub(in crate::analytics::tuner) order: Vec, } -/// The current order, rebuilt only when the rows, the sort or the "tunable only" switch +/// The current order, rebuilt only when the rows, the sort or the "with tape only" switch /// changed. pub(in crate::analytics::tuner) fn order_for(state: &mut TicksState) -> &[usize] { let fresh = state.order.as_ref().is_some_and(|c| { - c.rows_rev == state.rows_rev && c.sort == state.sort && c.only_tunable == state.only_tunable + c.rows_rev == state.rows_rev + && c.sort == state.sort + && c.only_with_tape == state.only_with_tape }); if !fresh { let rows: &[DealRow] = state.data.data().map(|d| d.rows.as_slice()).unwrap_or(&[]); let mut order: Vec = (0..rows.len()) - .filter(|&i| !state.only_tunable || rows[i].tunable()) + .filter(|&i| !state.only_with_tape || rows[i].tape == TapeStatus::Covered) .collect(); if let Some((key, desc)) = &state.sort { sort_indices(rows, &mut order, key, *desc); @@ -31,7 +33,7 @@ pub(in crate::analytics::tuner) fn order_for(state: &mut TicksState) -> &[usize] state.order = Some(OrderCache { rows_rev: state.rows_rev, sort: state.sort.clone(), - only_tunable: state.only_tunable, + only_with_tape: state.only_with_tape, order, }); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 165a6e3a..1c54ecd1 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -25,6 +25,8 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + hook_depth_pct: None, + hook_stated_take_pct: None, } } @@ -79,8 +81,8 @@ fn state() -> TicksState { rows[0].held = Some((60_000, 30_000)); rows[1].held = Some((60_000, 60_000)); let mut state = TicksState::default(); - // The sorts are exercised over every row; the "tunable only" switch has its own test. - state.only_tunable = false; + // The sorts are exercised over every row; the "with tape only" switch has its own test. + state.only_with_tape = false; state.data.apply(Ok(TicksData { rows, ..TicksData::default() @@ -106,27 +108,27 @@ fn the_default_order_is_newest_entry_first() { } #[test] -fn the_tunable_switch_keeps_only_covered_rows_the_model_reproduced() { +fn the_tape_switch_keeps_the_covered_rows_whatever_the_model_said() { // Row 2: covered, both groups ✓. Row 1: no tape. Row 3: refused, and the entry a miss. let mut state = state(); - assert!(TicksState::default().only_tunable, "on by default"); - state.only_tunable = true; + assert!( + !TicksState::default().only_with_tape, + "off by default: the rows without tape are what the fetch button is for" + ); + state.only_with_tape = true; assert_eq!(uids(&mut state), [2]); - assert_eq!(state.data.data().unwrap().tunable(), 1); - // A group the model does not answer is not a miss. - state.data.data_mut().unwrap().rows[1].verdict = Some(verdict(None, Some(true))); + // The verdict is the "model" column, never a filter: a covered row the model missed + // stays in the table, and a sample narrowed to what the model already fits would be + // fitted on itself. + state.data.data_mut().unwrap().rows[1].verdict = Some(verdict(Some(false), Some(false))); state.rows_rev += 1; assert_eq!(uids(&mut state), [2]); - // Nothing answered is nothing reproduced. - state.data.data_mut().unwrap().rows[1].verdict = Some(verdict(None, None)); + // A covered row with no verdict at all is in the sample too — the search replays it. + state.data.data_mut().unwrap().rows[1].verdict = None; state.rows_rev += 1; - assert_eq!(uids(&mut state), Vec::::new()); - // A group it got wrong is. - state.data.data_mut().unwrap().rows[1].verdict = Some(verdict(Some(true), Some(false))); - state.rows_rev += 1; - assert_eq!(uids(&mut state), Vec::::new()); + assert_eq!(uids(&mut state), [2]); // Flipping the switch alone rebuilds the order: the cache keys on it. - state.only_tunable = false; + state.only_with_tape = false; assert_eq!(uids(&mut state), [1, 3, 2]); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index d9472b01..101b41ce 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -72,22 +72,6 @@ pub(in crate::analytics::tuner) struct DealRow { pub(in crate::analytics::tuner) held: Option<(i64, i64)>, } -impl DealRow { - /// Whether the tuner counts this row: its tape covers the window and the model reproduces - /// the fact in every group it answers, having answered at least one. An unanswered group — - /// a kind without an entry model, an exit rule the model does not have — is not a miss; a - /// group the model got wrong is; a row it verified nothing about is not a reproduction - /// either, and a row without its tape has no verdict at all. - pub(in crate::analytics::tuner) fn tunable(&self) -> bool { - self.tape == TapeStatus::Covered - && self.verdict.is_some_and(|v| { - v.entry != Some(false) - && v.exit != Some(false) - && (v.entry == Some(true) || v.exit == Some(true)) - }) - } -} - /// Where a deal's prints live, as the replay worker keys them, plus what a fetch needs. #[derive(Clone, Debug)] pub(in crate::analytics::tuner) struct RowAddress { @@ -117,7 +101,8 @@ pub(in crate::analytics::tuner) struct TicksData { /// Scope rows without millisecond stamps — in the Fact column, not in the table. pub(in crate::analytics::tuner) without_ms: usize, /// Service rows with stamps the axis never takes (funding, liquidations, joined sells, no - /// strategy) — in the Fact column, not in the table. + /// strategy, and a sale that moved more coins than the entry bought — a spot position + /// topped up from the wallet balance) — in the Fact column, not in the table. pub(in crate::analytics::tuner) service: usize, /// Trades the tuner cannot be run on — container or unresolved kinds, manual exits — in /// the Fact column, not in the table. @@ -144,11 +129,6 @@ impl TicksData { .count() } - /// Rows the tuner counts — see [`DealRow::tunable`]; the status line's figure. - pub(in crate::analytics::tuner) fn tunable(&self) -> usize { - self.rows.iter().filter(|r| r.tunable()).count() - } - /// The exit horizon of the replayable sample, in milliseconds — the shortest HELD trail /// past the close among the rows the variants and the search replay, the same rule as /// `search::common_horizon_ms` over the same rows (`prepared_deals` hands it each row's @@ -271,10 +251,13 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) dirty: bool, /// `(column key, descending)` of the deal table. pub(in crate::analytics::tuner) sort: Option<(String, bool)>, - /// The table shows only the rows the tuner counts ([`DealRow::tunable`]); on by default, - /// because the rest — no tape yet, a fact the model missed — is what the status line - /// counts, not what the sample is. - pub(in crate::analytics::tuner) only_tunable: bool, + /// The table shows only the rows whose tape covers the window — the sample the variants + /// and the search actually run on ([`TicksData::replayable`]). Off by default: the rows + /// without their tape are the ones the fetch button exists for, and a filter that hides + /// them hides the work to be done. Whether the MODEL reproduces a row is not a filter — + /// it is the "model" column and the search gate (`SHARE_GATE`); a sample narrowed to what + /// the model already fits would be fitted on itself. + pub(in crate::analytics::tuner) only_with_tape: bool, /// The sorted row order, cached against `rows_rev` and the sort. pub(in crate::analytics::tuner) order: Option, /// Bumped whenever `data` changes, so the cached order is rebuilt. @@ -317,7 +300,7 @@ impl Default for TicksState { seq: 0, dirty: true, sort: Some((super::columns::COL_TIME.to_string(), true)), - only_tunable: true, + only_with_tape: false, order: None, rows_rev: 0, entry_open: true, diff --git a/locales/analytics.yml b/locales/analytics.yml index f224420e..789246e5 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1720,26 +1720,22 @@ analytics.ticks.coverage_untunable: ru: "вне тюнинга %{n}" en: "outside tuning %{n}" es: "fuera del ajuste %{n}" -analytics.ticks.tunable_n: - ru: "годных для тюнера %{n}" - en: "tunable %{n}" - es: "aptas para el ajuste %{n}" -analytics.ticks.only_tunable: - ru: "только годные" - en: "tunable only" - es: "solo aptas" -analytics.ticks.only_tunable_tip: - ru: "Показывать только сделки, по которым тюнер считает: лента покрывает окно, и модель воспроизводит вход и выход" - en: "Show only the trades the tuner counts: the tape covers the window, and the model reproduces the entry and the exit" - es: "Mostrar solo las operaciones que el ajuste cuenta: la cinta cubre la ventana y el modelo reproduce la entrada y la salida" -analytics.ticks.none_tunable: - ru: "Годных для тюнера сделок пока нет — %{hidden} скрыто галкой «только годные»" - en: "No tunable trades yet — %{hidden} hidden by \"tunable only\"" - es: "Aún no hay operaciones aptas — %{hidden} ocultas por «solo aptas»" +analytics.ticks.only_with_tape: + ru: "только с лентой" + en: "with tape only" + es: "solo con cinta" +analytics.ticks.only_with_tape_tip: + ru: "Показывать только сделки, чьё окно покрыто лентой, — это и есть выборка, по которой считаются варианты и идёт подбор. Воспроизводит ли модель сделку, показывает колонка «модель»; выборку это не сужает" + en: "Show only the trades whose window the tape covers — that is the sample the variants and the search run on. Whether the model reproduces a trade is the \"model\" column; it does not narrow the sample" + es: "Mostrar solo las operaciones cuya ventana cubre la cinta: esa es la muestra sobre la que se calculan las variantes y el ajuste. Si el modelo reproduce la operación lo indica la columna «modelo»; no reduce la muestra" +analytics.ticks.none_with_tape: + ru: "Сделок с лентой пока нет — %{hidden} скрыто галкой «только с лентой»" + en: "No trades with tape yet — %{hidden} hidden by \"with tape only\"" + es: "Aún no hay operaciones con cinta — %{hidden} ocultas por «solo con cinta»" analytics.ticks.empty_left_out: - ru: "В периоде нет сделок, по которым ось может считать: без мс-штампа %{without} (старые строки реплики или ядро до штампов — лента по ним не восстанавливается), служебных %{service} (фандинг, ликвидации, объединённые продажи, без стратегии), вне тюнинга %{untunable} (ручные продажи, стратегии без торгового правила)." - en: "The period holds no trade the axis can read: %{without} without millisecond stamps (older replica rows, or a core before the stamps — no tape can be tied to them), %{service} service rows (funding, liquidations, joined sells, no strategy), %{untunable} outside tuning (manual sells, strategies without a trading rule)." - es: "El periodo no tiene operaciones que el eje pueda leer: %{without} sin marcas de milisegundos (filas antiguas de la réplica o un núcleo anterior a las marcas — no hay cinta que atar), %{service} filas de servicio (funding, liquidaciones, ventas unidas, sin estrategia), %{untunable} fuera del ajuste (ventas manuales, estrategias sin regla de trading)." + ru: "В периоде нет сделок, по которым ось может считать: без мс-штампа %{without} (старые строки реплики или ядро до штампов — лента по ним не восстанавливается), служебных %{service} (фандинг, ликвидации, объединённые продажи, без стратегии, продажа сверх купленного количества), вне тюнинга %{untunable} (ручные продажи, стратегии без торгового правила)." + en: "The period holds no trade the axis can read: %{without} without millisecond stamps (older replica rows, or a core before the stamps — no tape can be tied to them), %{service} service rows (funding, liquidations, joined sells, no strategy, a sale bigger than its entry), %{untunable} outside tuning (manual sells, strategies without a trading rule)." + es: "El periodo no tiene operaciones que el eje pueda leer: %{without} sin marcas de milisegundos (filas antiguas de la réplica o un núcleo anterior a las marcas — no hay cinta que atar), %{service} filas de servicio (funding, liquidaciones, ventas unidas, sin estrategia, una venta mayor que su entrada), %{untunable} fuera del ajuste (ventas manuales, estrategias sin regla de trading)." analytics.ticks.fetch_btn: ru: "Прогрузить трейды" en: "Fetch trades" From aa1df88424656fc6dc0c7ba1eee867673b3d6629 Mon Sep 17 00:00:00 2001 From: guyverino Date: Tue, 22 Sep 2026 21:39:02 +0200 Subject: [PATCH 17/51] feat(tuner): the book-watching stop, UseStopLoss, and the archive's fill point in the verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/db/tuner/ticks/exit.rs | 17 ++ crates/moon-core/src/db/tuner/ticks/line.rs | 106 +++++++- .../src/db/tuner/ticks/line/tests.rs | 115 +++++++++ crates/moon-core/src/db/tuner/ticks/params.rs | 19 +- crates/moon-core/src/db/tuner/ticks/tests.rs | 86 +++++++ .../src/db/tuner/ticks/tests/real_data.rs | 38 +++ crates/moon-core/src/db/tuner/ticks/verify.rs | 226 +++++++++++++++--- 7 files changed, 569 insertions(+), 38 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index 6c495158..fc4ae446 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -98,8 +98,21 @@ pub struct ExitParams { pub sell_shot_allowed_down_pct: f64, pub sell_shot_delay_s: f64, // Stops + /// `StopLoss`, already zeroed by [`super::params::exit_params`] when `UseStopLoss` is off — + /// the field keeps its value in a strategy whose stop is switched off, and the core then + /// arms nothing. pub stop_loss_pct: f64, pub stop_loss_delay_s: f64, + /// `FastStopLoss` — what the stop watches. YES: the trades ("crosses", FAQ), so the first + /// print through the level fires it. NO — the core's default: the book's BID (the ASK for a + /// short), averaged over `StopLossEMA` of its own samples, which the trade tape does not + /// carry; the walk then reads a sampled proxy of it (see [`super::line`]). + pub fast_stop_loss: bool, + /// `StopLossEMA` — how many of the core's samples the non-fast stop averages (FAQ: 0 off, + /// 3/5/10 "the last 3, 5, 10 ticks", so that a single spike through the line does not start + /// the panic sell). Ignored by a fast stop — the FAQ's own distinction, and the live + /// activations agree: 48 fast stops with it at 3 fire as promptly as 81 without it. + pub stop_loss_ema: f64, /// Model parameter: how long a replacement of the sell takes to reach the book. pub latency_ms: f64, /// Verdict-only: start the line at the archived take (`Deal::archived_take`) for a kind @@ -150,6 +163,10 @@ impl Default for ExitParams { sell_shot_delay_s: 0.0, stop_loss_pct: 0.0, stop_loss_delay_s: 0.0, + // The trigger the tape itself carries; `exit_params` reads the strategy's own, and + // its absence there is the core's default, NO. + fast_stop_loss: true, + stop_loss_ema: 0.0, latency_ms: DEFAULT_LATENCY_MS, take_from_archive: false, } diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs index 6ddf042a..2a3be65a 100644 --- a/crates/moon-core/src/db/tuner/ticks/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/line.rs @@ -24,8 +24,13 @@ //! distance by that much per second past `SellShotPriceDownDelay`; the line stays between //! `SellShotAllowedDown` and `SellShotAllowedUp` per cent over the buy. //! - **StopLoss** — `StopLoss` per cent from the buy (negative: a loss), armed -//! `StopLossDelay` seconds after the buy; the first print through it is a market exit at -//! the print's own price. +//! `StopLossDelay` seconds after the buy. With `FastStopLoss` the first print through it is +//! a market exit at the print's own price. Without it — the core's default — the core +//! watches the book's BID (a short's ASK) averaged over `StopLossEMA` samples, and the walk +//! reads a proxy of that: the last print on that side of the book (a taker sell prints at the +//! BID), sampled every [`STOP_SAMPLE_MS`], averaged the same way; the exit is at the sample, +//! at the proxy's price. Where the core's panic sell then fills is the book's business — see +//! [`super::verify`] for how the fact is judged. //! //! Every rule is written for a long and mirrored for a short by [`Side`]. A replacement reaches //! the exchange `latency_ms` later, as the entry's does: a spike through the OLD level in that @@ -47,6 +52,17 @@ use crate::feed::types::Tick; /// The terminal's own floor on a step delay of zero: the FAQ's "0.33 s internal minimum". pub const STEP_FLOOR_MS: i64 = 330; +/// How often the non-fast stop's BID proxy is sampled. The core's own cadence is not in the +/// FAQ and the tape has no book, so this is a CALIBRATION, not the core's constant: against +/// the activation the order archive records (the sell line's jump past the stop), on 199 live +/// book-watching stops (2026-09-22), the first print through the level fired a median 3.9 s +/// early with `StopLossEMA` at 3 and 0.6 s with it off; sampling the proxy every 2 s and +/// averaging the samples brings both medians within 0.4 s and puts 64 of 98 (EMA off) and 35 +/// of 101 (EMA 3) within a second, against 53 and 25. Faster sampling left the EMA-3 stops +/// seconds early, 3 s left the rest a second late. What the proxy still cannot see is the book +/// itself, and the EMA-3 stops are where that shows. +pub const STOP_SAMPLE_MS: i64 = 2_000; + /// Which way the position profits, folding every "above/below the buy" into one sign. #[derive(Clone, Copy)] struct Side { @@ -97,6 +113,60 @@ impl Side { } } +/// The book-watching stop's state: a BID proxy — the last print on the stop's side of the +/// book — sampled every [`STOP_SAMPLE_MS`] and averaged over `StopLossEMA` samples. +struct BookStop { + long: bool, + level: f64, + /// The end of `StopLossDelay`: a sample before it is averaged but cannot fire. + armed_at: i64, + /// The EMA weight, `2 / (StopLossEMA + 1)`; 1 without averaging. + alpha: f64, + proxy: Option, + avg: Option, + next_sample: i64, +} + +impl BookStop { + /// Take every sample due strictly before `until` — the prints before it are all the + /// proxy has seen — and answer the first one whose average is past the level: the stop, + /// at the sample's moment and the proxy's price. + fn sample_before(&mut self, until: i64) -> Option { + while self.next_sample < until { + let at = self.next_sample; + self.next_sample += STOP_SAMPLE_MS; + let Some(bid) = self.proxy else { + continue; + }; + let avg = self + .avg + .map_or(bid, |a| self.alpha * bid + (1.0 - self.alpha) * a); + self.avg = Some(avg); + if at >= self.armed_at && reaches(avg, self.level, self.long) { + return Some(Exit { + t_ms: at, + price: bid, + kind: ExitKind::Stop, + }); + } + } + None + } + + /// Read a print into the proxy: a taker sell prints at the BID — a long's stop side; a + /// short's stop watches the ASK, where a taker buy prints. + fn see(&mut self, tick: &Tick) { + let stop_side = if self.long { + crate::feed::types::Side::Sell + } else { + crate::feed::types::Side::Buy + }; + if tick.side == stop_side { + self.proxy = Some(f64::from(tick.price)); + } + } +} + /// One replacement of the line, for the comparison with the archived Exit points. #[derive(Clone, Copy, Debug, PartialEq)] pub struct LinePoint { @@ -239,6 +309,18 @@ pub fn walk_held( let stop_on = stop != 0.0; let stop_level = side.over(fill.price, stop); let stop_from = fill.t_ms + (params.stop_loss_delay_s.max(0.0) * 1000.0) as i64; + // The non-fast stop's BID proxy: the last print on the stop's side of the book, sampled on + // its own clock and averaged over `StopLossEMA` samples (see `STOP_SAMPLE_MS`). + let book_stop = stop_on && !params.fast_stop_loss; + let mut book = book_stop.then(|| BookStop { + long: side.long, + level: stop_level, + armed_at: stop_from, + alpha: 2.0 / (params.stop_loss_ema.max(1.0) + 1.0), + proxy: None, + avg: None, + next_sample: fill.t_ms + STOP_SAMPLE_MS, + }); let mut last_t = fill.t_ms; for (index, tick) in ticks.iter().enumerate() { @@ -300,9 +382,18 @@ pub fn walk_held( exch_moved = true; pending = None; } - // The stop is a market order the core fires on the print; the sell is a limit the + // The book-watching stop samples between prints: every sample due BEFORE this print + // reads the proxy the earlier prints left, and one past the level fires at its own + // moment, ahead of anything this print does. + if let Some(book) = book.as_mut() { + if let Some(exit) = book.sample_before(t_ms) { + return LineWalk { exit, points }; + } + book.see(tick); + } + // The fast stop is a market order the core fires on the print; the sell is a limit the // print reaches. Both come before the print-driven rule below moves anything. - if stop_on && t_ms >= stop_from && reaches(price, stop_level, side.long) { + if stop_on && !book_stop && t_ms >= stop_from && reaches(price, stop_level, side.long) { return LineWalk { exit: Exit { t_ms, @@ -384,9 +475,14 @@ pub fn walk_held( } } } + let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(last_t); + // The book stop's samples up to the tape's end — the one AT the last print included — read + // the proxy the last prints left; the loop only ever reaches the samples before a print. + if let Some(exit) = book.as_mut().and_then(|book| book.sample_before(tail + 1)) { + return LineWalk { exit, points }; + } // Nothing closed it inside the tape. Not the report's own exit: a variant that never // closes is not a trade, whatever the core's rules did, and the caption counts it. - let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(last_t); LineWalk { exit: Exit { t_ms: tail, diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index 033ef2ef..2aef6938 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -347,6 +347,121 @@ fn the_stop_fires_on_the_print_after_its_delay() { assert!((w.exit.price - 98.7).abs() < 1e-4); } +fn sold(t_ms: i64, price: f64) -> Tick { + Tick { + side: TickSide::Sell, + ..tick(t_ms, price) + } +} + +/// The book-watching stop (`FastStopLoss` off) reads the BID through the prints that hit it — +/// taker sells — on its own sample clock, not every print through the level. +#[test] +fn the_book_stop_fires_on_a_sample_of_the_bid_not_on_a_print() { + let book = ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: false, + ..params() + }; + // A taker BUY through the level says nothing about the BID; the taker sell at 98.8 does, + // and the next sample after it — 4 s — fires, at the proxy's price. + let ticks = vec![ + tick(1_000, 98.5), + sold(1_500, 99.5), + sold(2_500, 98.8), + tick(5_000, 100.0), + ]; + let w = walk(&deal(false), &ticks, fill(), 101.0, &book); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 4_000)); + assert!((w.exit.price - 98.8).abs() < 1e-4); + // The fast stop takes the first print through the level, whichever side it hit. + let fast = ExitParams { + fast_stop_loss: true, + ..book.clone() + }; + let w = walk(&deal(false), &ticks, fill(), 101.0, &fast); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 1_000)); +} + +/// A sample due exactly at the tape's last print reads that print — the loop only reaches the +/// samples before a print, so the tape's end is where it must not be forgotten. +#[test] +fn the_book_stop_takes_the_sample_at_the_last_print() { + let book = ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: false, + ..params() + }; + let w = walk(&deal(false), &[sold(2_000, 98.8)], fill(), 101.0, &book); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 2_000)); + // A sample the tape ends before is not taken: nothing is known past the last print. + let w = walk(&deal(false), &[sold(1_500, 98.8)], fill(), 101.0, &book); + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd); +} + +/// `StopLossEMA` averages the samples, so a BID just past the level fires only once the +/// average is past it too. +#[test] +fn the_stop_ema_waits_for_the_average() { + let ticks = vec![sold(1_500, 99.5), sold(2_500, 98.9), sold(9_000, 98.9)]; + let plain = ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: false, + ..params() + }; + let w = walk(&deal(false), &ticks, fill(), 101.0, &plain); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 4_000)); + // Samples 99.5, 98.9, 98.9, 98.9 at 2, 4, 6, 8 s: the EMA over 3 (α = 0.5) reads 99.5, + // 99.2, 99.05, 98.975 — past 99 at the fourth. + let smoothed = ExitParams { + stop_loss_ema: 3.0, + ..plain + }; + let w = walk(&deal(false), &ticks, fill(), 101.0, &smoothed); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 8_000)); +} + +/// A book stop's sale is a panic sell walked through the book: the verdict holds the model's +/// stop level against the one the core printed, and the moment against the close — never the +/// sale price. A stop the core fired and the model never did is a miss. +#[test] +fn verify_judges_a_book_stop_by_its_level_and_moment() { + let book = ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: false, + ..params() + }; + let mut d = deal(false); + d.sell_reason = "StopLoss AutoActivated on price drop: BID = 98.800 ASK: 98.900 \ + (strategy ); StopLoss fixed: 99.000 AllowedDrop: BUY -15.0%" + .into(); + d.sell_price = 97.0; + d.close_ms = 4_050; + let ticks = vec![sold(1_500, 99.5), sold(2_500, 98.8), tick(5_000, 100.0)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit_kind, Some(ExitKind::Stop)); + assert_eq!(v.exit, Some(true), "{v:?}"); + assert!(v.exit_dev_pct.is_some_and(|dev| dev.abs() < 1e-9)); + // The stored reason cut inside the level: still the book stop, judged by its moment. + let full = d.sell_reason.clone(); + d.sell_reason = full[..full.find("99.000").expect("level") + 3].to_string(); + let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit, Some(true), "{v:?}"); + d.close_ms = 9_000; + let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit, Some(false), "five seconds off the moment, {v:?}"); + d.close_ms = 4_050; + d.sell_reason = full; + // The core's level elsewhere: not this stop. + d.sell_reason = d.sell_reason.replace("99.000", "98.000"); + let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit, Some(false), "{v:?}"); + // A tape whose BID never reaches the level: the core stopped, the model did not. + let calm = vec![sold(1_500, 99.5), tick(5_000, 100.0)]; + let v = verify(&d, &calm, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit, Some(false), "{v:?}"); +} + // ---- the mirror and the archive ------------------------------------------------------------- #[test] diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index b63ae84c..8bee1efc 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -376,6 +376,10 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "SellModifier", "MaxModifier", "StopLossModifier", + // The stop's switch and its trigger (see `ExitParams::fast_stop_loss`). + "UseStopLoss", + "FastStopLoss", + "StopLossEMA", "Add1minDelta", "Add5minDelta", "Add15minDelta", @@ -539,8 +543,21 @@ pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { sell_shot_allowed_up_pct: v.num("SellShotAllowedUp", base.sell_shot_allowed_up_pct), sell_shot_allowed_down_pct: v.num("SellShotAllowedDown", base.sell_shot_allowed_down_pct), sell_shot_delay_s: v.num("SellShotDelay", base.sell_shot_delay_s), - stop_loss_pct: v.num("StopLoss", base.stop_loss_pct), + // `StopLoss` means nothing with `UseStopLoss` off (param_deps.toml: every stop field + // hangs on it), and the value stays in the dump when the switch goes off. A dump that + // omits the switch keeps the stop, as the model did before it read the switch: 2 of + // 1 422 live strategies omit it, and nothing says which way their core defaults. + stop_loss_pct: if v.bool("UseStopLoss", true) { + v.num("StopLoss", base.stop_loss_pct) + } else { + 0.0 + }, stop_loss_delay_s: v.num("StopLossDelay", base.stop_loss_delay_s), + // Absent means the core default, NO: every live stop of a strategy that omits the field + // closed as "StopLoss AutoActivated on price drop: BID = …" (173 of 184 with a tape), + // the book-watching stop, never as the fast stop's "StopLoss Market Sell". + fast_stop_loss: v.bool("FastStopLoss", false), + stop_loss_ema: v.num("StopLossEMA", base.stop_loss_ema), latency_ms: base.latency_ms, take_from_archive: base.take_from_archive, } diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 580c826b..bde04320 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -921,6 +921,44 @@ fn verify_takes_a_limits_better_fill_and_ignores_the_archived_fill_point() { assert_eq!(v.exit, Some(false), "{v:?}"); } +/// A level placed THROUGH the market is taken at once by the book: the archive files the fill +/// a moment after the move, at the sale price and better than the level, and up to a second +/// before `closedatems` — the report books the close later. The fill is not a move, and the +/// improvement is the book's, not another exit's. +#[test] +fn verify_takes_a_level_placed_through_the_market() { + let mut d = deal(); + d.sell_price = 100.5; + d.close_ms = 10_500; + let ticks = tape(&[(0, 100.0), (10_000, 99.0), (10_030, 100.5)]); + // The take at 99.99 placed at the fill; the core's fill point 30 ms later at 100.5, 470 ms + // before the close — past the latency window the close stamp alone allowed. + let archived = [(10_000, 99.99), (10_030, 100.5)]; + let v = verify( + &d, + &ticks, + &EntryParams::Fact, + &ExitParams::default(), + None, + Some(&archived), + ); + assert_eq!(v.line_points, Some((1, 1)), "the fill point is not a move"); + assert_eq!(v.exit, Some(true), "{v:?}"); + // Worse than the level it follows is never its fill: a move the model did not make. + d.sell_price = 99.5; + let archived = [(10_000, 99.99), (10_030, 99.5)]; + let v = verify( + &d, + &ticks, + &EntryParams::Fact, + &ExitParams::default(), + None, + Some(&archived), + ); + assert_eq!(v.line_points, Some((1, 2))); + assert_eq!(v.exit, Some(false), "{v:?}"); +} + #[test] fn share_counts_only_answered_verdicts() { assert_eq!(share([Some(true), None, Some(false), Some(true)]), (2, 3)); @@ -989,6 +1027,54 @@ fn exit_params_read_the_sell_fields() { assert!((p.sell_price_adjust_pct - 1.0).abs() < 1e-9); } +/// The stop's switch and trigger: `StopLoss` stays in the dump of a strategy whose stop is off, +/// and a dump without `FastStopLoss` is at the core's default — the book-watching stop. +#[test] +fn exit_params_read_the_stop_switch_and_trigger() { + let defaults = HashMap::new(); + let read = |pairs: &[(&str, &str)]| { + let v = values(pairs); + exit_params(&StrategyValues { + values: &v, + defaults: &defaults, + }) + }; + let off = read(&[("UseStopLoss", "NO"), ("StopLoss", "-2")]); + assert_eq!(off.stop_loss_pct, 0.0, "a switched-off stop arms nothing"); + let on = read(&[ + ("UseStopLoss", "YES"), + ("StopLoss", "-2"), + ("StopLossEMA", "3"), + ]); + assert_eq!(on.stop_loss_pct, -2.0); + assert!(!on.fast_stop_loss, "absent is the core default, NO"); + assert_eq!(on.stop_loss_ema, 3.0); + let unswitched = read(&[("StopLoss", "-2"), ("FastStopLoss", "YES")]); + assert_eq!(unswitched.stop_loss_pct, -2.0, "no switch keeps the stop"); + assert!(unswitched.fast_stop_loss); +} + +#[test] +fn the_stated_stop_level_is_read_only_when_it_is_a_usable_price() { + use super::verify::stated_stop_level; + let reason = "StopLoss AutoActivated on price drop: BID = 0.025326 ASK: 0.025999 \ + (strategy ); StopLoss fixed: 0.025334 Allow"; + assert_eq!(stated_stop_level(reason), Some(0.025334)); + // A zero, or a level printed too coarsely for its price. + assert_eq!( + stated_stop_level("StopLoss fixed: 0.00000 AllowedDrop"), + None + ); + assert_eq!( + stated_stop_level("StopLoss fixed: 0.00012 AllowedDrop"), + None + ); + // Cut off by the column's length — live reasons end on `0.` — the digits are incomplete. + assert_eq!(stated_stop_level("StopLoss fixed: 0."), None); + assert_eq!(stated_stop_level("StopLoss fixed: 0.0253"), None); + assert_eq!(stated_stop_level("StopLoss Market Sell"), None); +} + #[test] fn the_descriptor_keys_every_field_the_builders_read_and_splits_the_groups() { let keys = param_keys(); diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 477aa071..53955995 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -199,6 +199,44 @@ fn real_data_reproduction() { eprintln!(" model line: {}", fmt(&mine)); eprintln!(" archive : {}", fmt(&moves)); } + // The walk the verdict itself judges — the factual entry, the archived take, the sell + // held through the close — with every archived move beside the nearest modelled one, + // so a miss on one point shows WHICH point and by how much. + { + let fact_exit = ExitParams { + take_from_archive: true, + ..exit.clone() + }; + let fact_fill = Fill { + t_ms: deal.buy_ms, + price: deal.buy_price, + }; + let held = + ExitModel::new(&fact_exit).walk_held(&deal, &ticks, fact_fill, deal.close_ms); + eprintln!( + " held exit {:?} at {:+}ms of close · stop {:.3}% · model pts {}", + held.exit.kind, + held.exit.t_ms - deal.close_ms, + super::super::exit::stop_pct(&exit, &deal), + held.points.len() + ); + if let Some(points) = exit_points.as_deref() { + for (t, p) in verify::archived_replacements(points) { + let near = held + .points + .iter() + .min_by_key(|m| (m.t_ms - t).abs()) + .map(|m| (m.t_ms - t, (m.price - p) / p * 100.0)); + eprintln!( + " arch {:+}ms {:.8} (close {:+}ms) near {:?}", + t - deal.buy_ms, + p, + t - deal.close_ms, + near.map(|(dt, dp)| (dt, (dp * 1000.0).round() / 1000.0)) + ); + } + } + } let plain = verify(&deal, &ticks, &entry, &exit, None, None); let archived = verify( &deal, diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 64e417d5..e3c6fe3a 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -33,13 +33,21 @@ //! [`PRICE_TOLERANCE`]. A model that lands on the right price by a different path has not //! reproduced the rule. Which PRINT the model would have sold on is not judged: that 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. A stop is the one -//! exit judged by its firing: it is a market order on the print, not a resting line. +//! sold the core's line on ARX and left it standing on COOL the same day. The archive's own +//! record of the fill — its last point, at the sale price — is not a move ([`is_fill_point`]). +//! +//! A stop is judged by its firing, not by a resting line: the fast stop by its price, a +//! market order on the print; the book-watching stop, whose sale is a panic sell walked +//! through a book the tape does not carry, by the level the core printed into its reason and +//! the moment it activated ([`verify_stop`]). A stop the core fired and the model never did is +//! a miss. -use super::exit::ExitModel; +use super::exit::{ExitModel, stop_pct}; use super::line::LinePoint; use super::mshot::MshotParams; -use super::{Deal, EntryParams, Exit, ExitKind, ExitParams, Fill, PRICE_TOLERANCE, simulate}; +use super::{ + Deal, EntryParams, Exit, ExitKind, ExitParams, Fill, PRICE_TOLERANCE, reaches, simulate, +}; use crate::feed::types::Tick; /// How far apart a modelled and an archived replacement may be in time and still be the same @@ -71,10 +79,12 @@ pub struct Verdict { /// Exit reproduced — the line's level at the close against the price the core sold at, /// and every archived move re-placed; `None` when the core closed by a rule other than the /// one the model's line was under at the close (a take against an "Auto Price Down" - /// fact, a line against a stop the model never fired) — the two prices are not comparable - /// then. `Some(false)` when no level stood at the close at all. + /// fact) — the two prices are not comparable then. `Some(false)` when no level stood at + /// the close at all, and when the core's exit was a stop the model — holding a stop of its + /// own — never fired. pub exit: Option, - /// Modelled exit against the fact, per cent of the fact. + /// Modelled exit against the fact, per cent of the fact — for a book-watching stop, the + /// modelled stop LEVEL against the level the core printed (see [`verify_stop`]). pub exit_dev_pct: Option, /// The modelled fill, for the tooltip. pub fill: Option, @@ -187,50 +197,69 @@ pub fn verify( // Where the take itself is not modelled for this trade, the line under it is not the // model's answer but its guess — see the module doc. let take_known = ExitModel::new(&fact_exit).take_known(deal); - let (exit_ok, exit_dev, line_points) = if !take_known && closed.kind != ExitKind::Stop { + // A stop the core fired and the model, holding a stop of its own, never did — the book + // proxy of a non-fast stop can stay short of the level to the tape's end — is a miss of + // the stop, whatever the line was doing: not a question about another rule. + let missed_stop = + fact_stopped && closed.kind != ExitKind::Stop && stop_pct(&fact_exit, deal) != 0.0; + let (exit_ok, exit_dev, line_points) = if missed_stop { + (Some(false), None, None) + } else if !take_known && closed.kind != ExitKind::Stop { (None, None, None) } else if closed.kind == ExitKind::OpenAtWindowEnd { // No line stood at the close: a miss of the exit group, not an unanswered question. (Some(false), None, None) + } else if closed.kind == ExitKind::Stop && exit_rule_matches(closed.kind, &deal.sell_reason) { + verify_stop(deal, &fact_exit, &walked.points, closed, exit_points) } else if exit_rule_matches(closed.kind, &deal.sell_reason) { let dev = deviation_pct(closed.price, deal.sell_price); - let tolerance = if closed.kind == ExitKind::Stop { - STOP_PRICE_TOLERANCE - } else { - PRICE_TOLERANCE - }; + let tolerance = PRICE_TOLERANCE; // Within the tolerance either way, or a limit's fill on the better side of its level: // `dev` is the model against the fact, so a fact above the modelled sell (a long) or // below the modelled buy-back (a short) reads as a negative deviation of the model. - let improved = |d: f64| match closed.kind { - ExitKind::Stop => false, - _ => { - let better = if deal.is_long() { -d } else { d }; - better > 0.0 && better <= FILL_IMPROVEMENT_TOLERANCE * 100.0 - } + let better_by = |d: f64| if deal.is_long() { -d } else { d }; + let improved = |d: f64| { + let better = better_by(d); + better > 0.0 && better <= FILL_IMPROVEMENT_TOLERANCE * 100.0 }; - // The archive's last point AT the sale — within the model's latency of the close, at - // the price the core sold at (GUN 2026-09-21: 31 ms before it, at the average fill) - // — is the fill filed as a point, not a move of the line; a re-placement any earlier, - // or at another price, is a move the model has to have made. - let fill_window_ms = exit.latency_ms.max(0.0) as i64; + let mut archived_fill: Option<(i64, f64)> = None; + let mut archived_level: Option<(i64, f64)> = None; let points = exit_points.filter(|p| !p.is_empty()).map(|archived| { let mut moves = archived_replacements(archived); if moves.len() > 1 - && moves.last().is_some_and(|&(t, p)| { - (t - deal.close_ms).abs() <= fill_window_ms - && deviation_pct(p, deal.sell_price) - .is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0) - }) + && moves + .last() + .is_some_and(|&last| is_fill_point(deal, exit, last, moves[moves.len() - 2])) { - moves.pop(); + archived_fill = moves.pop(); } + archived_level = moves.last().copied(); (matched_points(&walked.points, &moves), moves.len()) }); let line_ok = points.is_none_or(|(matched, total)| matched == total); let corroborated = points.is_some_and(|(matched, total)| matched == total); - let price_ok = - dev.is_some_and(|d| d.abs() <= tolerance * 100.0 || (corroborated && improved(d))); + // A level placed THROUGH the market: the archive filed the fill as a point of its own + // within a moment of the last move, the model re-placed at every move before it, and + // the line stood where that last move put it. The rule is reproduced, and how far past + // the level the fill landed is the book's — a marketable limit takes the best bid: + // INDEX 2026-09-22, the line at 0.03460 bought back 16 ms later at 0.0343988, 0.6 % + // better, the model on every one of the nine moves before it; live fills of this shape + // follow their move by a median 31 ms. A level that RESTED before its fill keeps the + // `FILL_IMPROVEMENT_TOLERANCE` bound — a fill far past a resting level is another exit. + // Held only on the better side: a limit never fills worse than its price. + let level_reproduced = corroborated + && archived_fill + .zip(archived_level) + .is_some_and(|(fill, level)| { + fill.0 - level.0 <= POINT_TIME_TOLERANCE_MS + && deviation_pct(closed.price, level.1) + .is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0) + }); + let price_ok = dev.is_some_and(|d| { + d.abs() <= tolerance * 100.0 + || (corroborated && improved(d)) + || (level_reproduced && better_by(d) >= -tolerance * 100.0) + }); (Some(price_ok && line_ok), dev, points) } else { (None, None, None) @@ -246,6 +275,139 @@ pub fn verify( } } +/// Whether the archive's last move is the FILL filed as a point rather than a move of the +/// line: at the price the core sold at, and either within the model's latency of the close +/// (GUN 2026-09-21: 31 ms before it) or on the fill side of the level before it — a limit +/// fills at its price or better, and no rule moves a sell line the instant after placing it. +/// +/// The close stamp alone missed most of them. On the live sample (2026-09-22) the fill point +/// sat a median 250 ms before `closedatems` and up to a second — the report stamps the close +/// when the core books it — so 145 "Auto Price Down" trades failed on that one point alone, +/// the model having re-placed at every move before it. 447 archived lines end on the better +/// side of their last level, at the sale price, most within 100 ms of it. +/// +/// Args: +/// deal: The report row — its sale price, close and side. +/// exit: The parameters, for the model's latency. +/// last: The archive's last move. +/// prev: The move before it. +fn is_fill_point(deal: &Deal, exit: &ExitParams, last: (i64, f64), prev: (i64, f64)) -> bool { + let (t, p) = last; + let at_sale = + deviation_pct(p, deal.sell_price).is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0); + if !at_sale { + return false; + } + let at_close = (t - deal.close_ms).abs() <= exit.latency_ms.max(0.0) as i64; + // Not worse than the level it was filed against: at or above a long's sell, at or below a + // short's buy-back — `reaches` with the long's side reads "at or above". + let fill_side = reaches(p, prev.1, !deal.is_long()); + at_close || fill_side +} + +/// The stop's verdict. Two stops, told apart by what the core wrote: +/// +/// - **With its level in the reason** — `StopLoss AutoActivated on price drop: BID = … StopLoss +/// fixed: X` — the book-watching stop (`FastStopLoss` off). The core then runs a panic sell: +/// a limit through the book stepped by `StopLossSpread` down to `AllowedDrop` (FAQ), which is +/// where the sale price comes from, and the tape has no book. So the rule is judged by what +/// it decided — the modelled stop level against the core's own `X`, and the moment it fired +/// against the activation — never by the fill. Live sample (2026-09-22): 0 of 173 such stops +/// passed on the sale price, the fills sitting 1–3 % past the level while the core's `X` +/// agreed with the model's level within 0.3 % on 144 of 183. When the stored reason cut the +/// level off, the moment and the line are what is left to judge. +/// - **Without it** — `StopLoss Market Sell`, the fast stop — a market order on the print, +/// judged by its price against the sale as before. +/// +/// The level tolerance is [`STOP_PRICE_TOLERANCE`] rather than the line's: the modelled level +/// carries `StopLossModifier` over the report's ONE snapshot of the deltas, which the core +/// re-reads live (see `exit::modifier_sum`), and the residual sits right there. +/// +/// Archived moves from the activation on — the first move past the stop level — are the panic +/// sell, not the line the rules moved, and are not held against the model. +/// +/// Args: +/// deal: The report row. +/// exit: The parameters the fact is replayed with. +/// modelled: Every level the modelled line stood at. +/// closed: The modelled stop. +/// exit_points: The archived Exit line, when the archive holds it. +fn verify_stop( + deal: &Deal, + exit: &ExitParams, + modelled: &[LinePoint], + closed: Exit, + exit_points: Option<&[(i64, f64)]>, +) -> (Option, Option, Option<(usize, usize)>) { + let stop = stop_pct(exit, deal); + let level = if deal.is_long() { + deal.buy_price * (1.0 + stop / 100.0) + } else { + deal.buy_price * (1.0 - stop / 100.0) + }; + let stated = stated_stop_level(&deal.sell_reason); + let panic_at = stated.unwrap_or(level); + let mut activation: Option = None; + let points = exit_points.filter(|p| !p.is_empty()).map(|archived| { + let mut moves = archived_replacements(archived); + if let Some(i) = moves + .iter() + .position(|&(t, p)| t >= deal.buy_ms && reaches(p, panic_at, deal.is_long())) + { + activation = Some(moves[i].0); + moves.truncate(i); + } + (matched_points(modelled, &moves), moves.len()) + }); + let line_ok = points.is_none_or(|(matched, total)| matched == total); + let on_time = + (closed.t_ms - activation.unwrap_or(deal.close_ms)).abs() <= POINT_TIME_TOLERANCE_MS; + match stated { + Some(stated) => { + let dev = deviation_pct(level, stated); + let level_ok = dev.is_some_and(|d| d.abs() <= STOP_PRICE_TOLERANCE * 100.0); + (Some(level_ok && on_time && line_ok), dev, points) + } + // A book-watching stop whose level the stored reason cut off (28 of 206 live): its sale + // is still the panic sell, which never passes on price (0 of 173), so what is left to + // judge is the moment and the line — not a sale price that would fail every one. + None if is_book_stop_reason(&deal.sell_reason) => (Some(on_time && line_ok), None, points), + None => { + let dev = deviation_pct(closed.price, deal.sell_price); + let price_ok = dev.is_some_and(|d| d.abs() <= STOP_PRICE_TOLERANCE * 100.0); + (Some(price_ok && line_ok), dev, points) + } + } +} + +/// Whether a stop's `sellreason` is the book-watching stop's — `StopLoss AutoActivated on price +/// drop: BID = …` — rather than the fast stop's `StopLoss Market Sell`. The prefix survives the +/// column's truncation, which cuts the text's end. +fn is_book_stop_reason(reason: &str) -> bool { + reason.trim().starts_with("StopLoss AutoActivated") +} + +/// The stop level the core printed into a book-watching stop's reason — `StopLoss fixed: X` — +/// when it is a usable price: positive, not cut off by the column's length, and printed finely +/// enough that its rounding sits inside [`PRICE_TOLERANCE`]. The reason is stored truncated, +/// and the level sits near its end: 28 of the 206 live reasons that carry it end inside the +/// number (`StopLoss fixed: 0.`), which would read as a stop at zero — so a number running +/// into the end of the text answers `None`, and [`verify_stop`] judges that stop by its moment +/// and its line alone. +pub fn stated_stop_level(reason: &str) -> Option { + const MARKER: &str = "StopLoss fixed:"; + let at = reason.find(MARKER)? + MARKER.len(); + let rest = reason[at..].trim_start(); + let len = rest + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .filter(|&len| len > 0)?; + let token = &rest[..len]; + let value: f64 = token.parse().ok()?; + let decimals = token.split_once('.').map_or(0, |(_, frac)| frac.len()); + let half_unit = 0.5 * 10f64.powi(-(decimals as i32)); + (value.is_finite() && value > 0.0 && half_unit / value <= PRICE_TOLERANCE).then_some(value) +} + /// How far a modelled entry may sit from the fact and still be the same order, per cent: the /// corridor's own width (`MShotPrice − MShotPriceMin` with the trade's modifiers), floored at /// [`PRICE_TOLERANCE`] — see the module doc. From d6e6f0cb1a3775bea48222e3ef89747b799597d1 Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 00:00:45 +0200 Subject: [PATCH 18/51] feat(tuner): chain PriceDown off the placed price, judge the fill on the archive's clock, per-core step lag and PumpMove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../moon-core/src/db/tuner/ticks/calibrate.rs | 72 ++++++ .../src/db/tuner/ticks/calibrate/tests.rs | 101 ++++++++ crates/moon-core/src/db/tuner/ticks/deals.rs | 1 + crates/moon-core/src/db/tuner/ticks/exit.rs | 8 + crates/moon-core/src/db/tuner/ticks/line.rs | 128 +++++++++-- .../src/db/tuner/ticks/line/tests.rs | 216 +++++++++++++++++- crates/moon-core/src/db/tuner/ticks/mod.rs | 5 + crates/moon-core/src/db/tuner/ticks/params.rs | 6 + .../src/db/tuner/ticks/search/tests.rs | 1 + .../src/db/tuner/ticks/stats/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/tests.rs | 2 + .../src/db/tuner/ticks/tests/real_data.rs | 182 ++++++++++++++- crates/moon-core/src/db/tuner/ticks/verify.rs | 189 ++++++++++++--- .../src/analytics/tuner/ticks/fetch.rs | 8 +- .../src/analytics/tuner/ticks/lags.rs | 80 +++++++ .../src/analytics/tuner/ticks/load.rs | 4 + .../src/analytics/tuner/ticks/mod.rs | 1 + .../src/analytics/tuner/ticks/rows/tests.rs | 1 + .../src/analytics/tuner/ticks/state.rs | 29 ++- 19 files changed, 960 insertions(+), 75 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/calibrate.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate.rs b/crates/moon-core/src/db/tuner/ticks/calibrate.rs new file mode 100644 index 00000000..f6fa943a --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/calibrate.rs @@ -0,0 +1,72 @@ +//! What the model reads off the core's own record rather than off the FAQ: the timing of the +//! core's sell moves, which depends on the machine and the venue the core runs on, not on any +//! strategy field. +//! +//! **The step lag.** The core times each PriceDown step from the moment the previous one went +//! through, so its steps come `PriceDownDelay` plus that core's replace round trip apart, and a +//! chain of them drifts off a schedule of whole delays. Read off the archived Exit lines +//! (2026-09-22, 6 349 consecutive steps): the median lag is 47 ms on GateF, 63 on Bitget1, 16 on +//! BB1, 31 on the BinF cores, 0–1 on F1…F6 — per core, not per venue (two Binance machines sit +//! 30 ms apart). A 30-step chain on a 30 s delay (HEI, PumpsDetection) ends 1.7 s off the +//! schedule, past the point tolerance. The caller calibrates each core from the archived lines +//! it holds ([`step_lag_samples`], [`median_step_lag`]) and hands the result to the model on +//! [`super::Deal::step_lag_ms`]. + +use super::Deal; +use super::exit::ExitParams; +use super::line::step_ms; +use super::verify::ArchivedExit; + +/// Fewest samples a core's lag is taken from; below it the core runs on the plain schedule. +pub const MIN_STEP_LAG_SAMPLES: usize = 5; + +/// One deal's PriceDown step-lag samples: for every pair of consecutive archived moves from the +/// first step on, how much later than `PriceDownDelay` the second came, in milliseconds. +/// +/// A pair further apart than one and a half delays had a step between them that rounding kept +/// in place and is left out, and so does a pair closer than one delay — a move of another rule +/// (the pump move, a SellLevel) sits between them. The archive's fill point is left out, told +/// apart the way the verdict tells it. Nothing when PriceDown is off. +/// +/// Args: +/// deal: The report row — its sale price. +/// exit: The deal's sell parameters, for the PriceDown timer and delay. +/// exit_points: The deal's archived Exit line. +pub fn step_lag_samples(deal: &Deal, exit: &ExitParams, exit_points: &[(i64, f64)]) -> Vec { + if exit.price_down_timer_s <= 0.0 || exit.price_down_pct <= 0.0 { + return Vec::new(); + } + // The fill point the way the verdict tells it (`verify::ArchivedExit`): a fill through the + // market lands well past the sale's tolerance of its level and is still no step. + let moves = ArchivedExit::of(deal, exit, exit_points).moves; + let delay_ms = step_ms(exit.price_down_delay_s); + // From the second move on: the first is the take, and the step after it is timed off the + // take by `PriceDownTimer`, not off a step before it. + moves + .windows(2) + .skip(1) + .map(|pair| pair[1].0 - pair[0].0 - delay_ms) + .filter(|lag| (0..delay_ms / 2).contains(lag)) + .collect() +} + +/// A core's step lag: the median of its samples — the mean of the two middle ones for an even +/// count — or `None` below [`MIN_STEP_LAG_SAMPLES`]. +/// +/// Args: +/// samples: Every sample of the core's deals, in any order; sorted in place. +pub fn median_step_lag(samples: &mut [i64]) -> Option { + if samples.len() < MIN_STEP_LAG_SAMPLES { + return None; + } + samples.sort_unstable(); + let mid = samples.len() / 2; + Some(if samples.len().is_multiple_of(2) { + (samples[mid - 1] + samples[mid]) as f64 / 2.0 + } else { + samples[mid] as f64 + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs new file mode 100644 index 00000000..aa55514e --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs @@ -0,0 +1,101 @@ +//! The step-lag calibration on synthetic archived lines. + +use super::*; +use crate::db::tuner::ticks::Deltas; + +fn deal() -> Deal { + Deal { + report_uid: 1, + core_uid: 21, + core_name: String::new(), + strategy_id: 42, + kind: "MoonHook".into(), + coin: "COOL".into(), + buy_ms: 0, + close_ms: 60_000, + buy_price: 100.0, + sell_price: 100.2, + spent: 1_000.0, + is_short: false, + sell_reason: "Auto Price Down".into(), + fact_pnl: 0.2, + profit: None, + deltas: Deltas::default(), + tick: None, + pre_spike_ask: None, + archived_take: None, + hook_depth_pct: None, + hook_stated_take_pct: None, + step_lag_ms: 0.0, + } +} + +/// PriceDown every second after a 1 s timer. +fn price_down() -> ExitParams { + ExitParams { + price_down_timer_s: 1.0, + price_down_delay_s: 1.0, + price_down_pct: 10.0, + ..ExitParams::default() + } +} + +/// The lag of every step after the first, off the core's own clock: the first step is timed off +/// the take, not off a step before it, and does not count. +#[test] +fn samples_are_the_steps_past_their_delay() { + let archived = [ + (0, 101.0), + (1_130, 100.9), + (2_170, 100.8), + (3_220, 100.7), + (4_250, 100.6), + ]; + assert_eq!( + step_lag_samples(&deal(), &price_down(), &archived), + vec![40, 50, 30] + ); +} + +/// A pair two delays apart had a step between them that rounding kept in place; a pair closer +/// than one delay has another rule's move between them. Neither is a lag. +#[test] +fn skipped_and_foreign_steps_are_not_samples() { + let archived = [ + (0, 101.0), + (1_100, 100.9), + (2_140, 100.8), + (4_190, 100.7), // a kept-in-place step at ~3.1 s + (4_400, 100.65), // another rule's move + (5_450, 100.6), + ]; + assert_eq!( + step_lag_samples(&deal(), &price_down(), &archived), + vec![40, 50] + ); +} + +/// The archive's fill point is no step, even one that lands a delay and a lag after the last +/// step and on the better side of it, as a limit's fill does. +#[test] +fn the_fill_point_is_not_a_step() { + let mut d = deal(); + d.sell_price = 100.85; + let archived = [(0, 101.0), (1_100, 100.9), (2_140, 100.8), (3_170, 100.85)]; + assert_eq!(step_lag_samples(&d, &price_down(), &archived), vec![40]); +} + +/// No PriceDown, no samples — whatever the line did. +#[test] +fn a_line_without_price_down_gives_nothing() { + let archived = [(0, 101.0), (1_100, 100.9), (2_140, 100.8)]; + assert!(step_lag_samples(&deal(), &ExitParams::default(), &archived).is_empty()); +} + +/// The median, and nothing from too few samples to trust. +#[test] +fn the_core_lag_is_the_median_of_enough_samples() { + assert_eq!(median_step_lag(&mut [60, 10, 40, 50, 30]), Some(40.0)); + assert_eq!(median_step_lag(&mut [60, 10, 40, 50, 30, 70]), Some(45.0)); + assert_eq!(median_step_lag(&mut [10, 40, 50, 30]), None); +} diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index 72a9f8d3..c15aa8f1 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -212,6 +212,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { // Filled by `overlay_hook_detect` off the raw report row's comment. hook_depth_pct: None, hook_stated_take_pct: None, + step_lag_ms: 0.0, }); order.push((close_ms, report_uid)); } diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index fc4ae446..a184083a 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -97,6 +97,12 @@ pub struct ExitParams { pub sell_shot_allowed_up_pct: f64, pub sell_shot_allowed_down_pct: f64, pub sell_shot_delay_s: f64, + // PumpMove (PumpsDetection) + /// `PumpMoveTimer` — seconds after the take before the one pump move; 0 never moves. + pub pump_move_timer_s: f64, + /// `PumpMovePersent` (the core's spelling) — per cent of the peak-to-buy distance the move + /// stops short of the peak. + pub pump_move_pct: f64, // Stops /// `StopLoss`, already zeroed by [`super::params::exit_params`] when `UseStopLoss` is off — /// the field keeps its value in a strategy whose stop is switched off, and the core then @@ -161,6 +167,8 @@ impl Default for ExitParams { sell_shot_allowed_up_pct: 10.0, sell_shot_allowed_down_pct: -100.0, sell_shot_delay_s: 0.0, + pump_move_timer_s: 0.0, + pump_move_pct: 0.0, stop_loss_pct: 0.0, stop_loss_delay_s: 0.0, // The trigger the tape itself carries; `exit_params` reads the strategy's own, and diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs index 2a3be65a..597d2904 100644 --- a/crates/moon-core/src/db/tuner/ticks/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/line.rs @@ -23,6 +23,10 @@ //! after `SellShotReplaceDelay` when moving toward it; `SellShotPriceDown` narrows the //! distance by that much per second past `SellShotPriceDownDelay`; the line stays between //! `SellShotAllowedDown` and `SellShotAllowedUp` per cent over the buy. +//! - **PumpMove** (PumpsDetection's own tab; `PumpMoveTimer` non-zero) — once, `PumpMoveTimer` +//! seconds after the take is placed, the sell moves to `PumpMovePersent` per cent of the way +//! from the pump's peak back to the buy (FAQ: "учитывается процент между пиковой ценой и ценой +//! покупки"), the peak read over [`PUMP_PEAK_LOOKBACK_MS`] before the take up to the move. //! - **StopLoss** — `StopLoss` per cent from the buy (negative: a loss), armed //! `StopLossDelay` seconds after the buy. With `FastStopLoss` the first print through it is //! a market exit at the print's own price. Without it — the core's default — the core @@ -37,10 +41,10 @@ //! gap fills there. The line's replacements are recorded so the model can be held against the //! archived Exit line of the trade. //! -//! The rules move an UNROUNDED line — the archive shows the core chaining its PriceDown steps -//! off the exact value, not the placed price — and only what goes to the exchange is rounded -//! to the nearest step of the market's price grid (a sell limit is placed on the grid). The -//! rounding is what decides a print AT the level: on ARX (2026-09-21) the +//! What goes to the exchange is rounded to the nearest step of the market's price grid (a sell +//! limit is placed on the grid), and the rules carry on from the ORDER's price once a move +//! reached the book — from the computed value while rounding kept the order where it was +//! ([`advance`]). The rounding is what decides a print AT the level: on ARX (2026-09-21) the //! `PriceDownAllowedDrop` floor computed to 0.196445, the core's order stood at 0.1964, and //! the tape's high was exactly 0.1964 — the unrounded line was never reached. @@ -63,6 +67,20 @@ pub const STEP_FLOOR_MS: i64 = 330; /// itself, and the EMA-3 stops are where that shows. pub const STOP_SAMPLE_MS: i64 = 2_000; +/// How far past `PumpMoveTimer` the core's pump move lands, less the model's own placement +/// latency: over 32 archived PumpsDetection lines (2026-09-22, every live Pump strategy runs +/// `PumpMoveTimer` 2 with `PumpMovePersent` 1) the move came 575–704 ms past the timer, a +/// median of 610 ms — counted from the take, not from the buy's report stamp: one take placed +/// 32 s after `buydatems` still moved 2.6 s after itself. +pub const PUMP_MOVE_LAG_MS: i64 = 500; + +/// How far before the take the pump's peak is looked for. The FAQ counts the peak from the +/// detect, which the report does not stamp on older rows, and the peak itself is the print that +/// triggered it — a median 70 ms before the buy, up to 6 s when the buy order waited for the +/// retrace. Over the same 32 lines a window from 10 s before the take reproduces the moved level +/// on 31; from the buy it reproduces 1, from 4 s before the take 27. +pub const PUMP_PEAK_LOOKBACK_MS: i64 = 10_000; + /// Which way the position profits, folding every "above/below the buy" into one sign. #[derive(Clone, Copy)] struct Side { @@ -181,8 +199,36 @@ pub struct LineWalk { pub points: Vec, } +/// One step of the core's sell price: `level` is what a rule computed, `order` that level on +/// the price grid. When the order lands on a new price, the move goes to the book and the core +/// carries on from the ORDER's price; when rounding keeps it where it was, nothing is sent and +/// the core carries on from the computed value, so the next step can still cross a grid line. +/// Answers whether the order moved. +/// +/// Read off the archived Exit lines (2026-09-22): every step of INDEX's twelve (Gate, relative +/// PriceDown 10 %) lands only when chained off the placed price — off the exact value the second +/// already rounds a step short — and FATCOIN's one-step-per-tick lines climb past a step that +/// rounds back onto the order only when that step's exact value carries into the next. Over +/// 1 421 archived PriceDown lines the rule reproduces every level of 997, against 647 for the +/// exact chain and 949 for the placed price alone. +/// +/// Args: +/// core: The core's sell price, advanced in place. +/// last_sent: The order's price as last sent, advanced when the order moves. +/// level: The level a rule computed. +/// order: `level` on the price grid. +fn advance(core: &mut f64, last_sent: &mut f64, level: f64, order: f64) -> bool { + if (order - *last_sent).abs() <= f64::EPSILON * last_sent.abs() { + *core = level; + return false; + } + *core = order; + *last_sent = order; + true +} + /// Seconds to milliseconds, with the terminal's floor for a zero delay. -fn step_ms(seconds: f64) -> i64 { +pub(super) fn step_ms(seconds: f64) -> i64 { let ms = (seconds * 1000.0) as i64; if ms <= 0 { STEP_FLOOR_MS } else { ms } } @@ -229,25 +275,34 @@ pub fn walk_held( t_ms: armed_at, price: take_placed, }]; - // The exchange's level (what fills, on the grid) and the core's (what the rules move, - // unrounded); a move the exchange has not seen yet is `pending`. + // The exchange's level (what fills, on the grid) and the core's (what the rules move from); + // a move the exchange has not seen yet is `pending`. let mut exch_line = take_placed; // Whether a move has reached the exchange: what tells a fill at the take from a fill at // a level a rule moved the line to — not the price, which a moved line can round back onto. let mut exch_moved = false; - let mut core_line = take; + // The core's sell price: the ORDER's price once a move reached the book, the computed value + // while rounding kept the order where it was — see [`advance`]. The take is an order too. + let mut core_line = take_placed; + // The last level sent to the book, pending or not: what a new level must differ from to + // be a move at all. + let mut last_sent = take_placed; let mut pending: Option<(i64, f64)> = None; + // Answers whether the order moved — a replace went to the book. let mut place = |t_ms: i64, level: f64, core: &mut f64, pending: &mut Option<(i64, f64)>| { if (level - *core).abs() <= f64::EPSILON * core.abs() { - return; + return false; } - *core = level; - let level = placed(level); - *pending = Some((t_ms + latency_ms, level)); + let order = placed(level); + if !advance(core, &mut last_sent, level, order) { + return false; + } + *pending = Some((t_ms + latency_ms, order)); points.push(LinePoint { t_ms: t_ms + latency_ms, - price: level, + price: order, }); + true }; // --- PriceDown --- @@ -259,6 +314,10 @@ pub fn walk_held( }; let pd_floor = side.over(fill.price, params.price_down_allowed_drop_pct); + // --- PumpMove --- one move, timed off the take (see `PUMP_MOVE_LAG_MS`). + let mut pm_next = (params.pump_move_timer_s > 0.0) + .then(|| armed_at + (params.pump_move_timer_s * 1000.0) as i64 + PUMP_MOVE_LAG_MS); + // --- SellLevel --- let sl_on = params.sell_level_delay_s != 0.0 && params.sell_level_time_s > 0.0 @@ -334,8 +393,32 @@ pub fn walk_held( // step due by this print happened BEFORE it, and a step that also reached the book // before it is what this print meets. // - // PriceDown steps, one per due moment. - while let Some(due) = pd_next.filter(|due| t_ms >= *due) { + // PriceDown steps, one per due moment, and the pump move, in the order they fell due: + // each step chains off where the one before it left the line. + loop { + let pd_due = pd_next.filter(|due| t_ms >= *due); + let pm_due = pm_next.filter(|due| t_ms >= *due); + if let Some(due) = pm_due.filter(|pm| pd_due.is_none_or(|pd| *pm <= pd)) { + pm_next = None; + let from = armed_at - PUMP_PEAK_LOOKBACK_MS; + let peak = side.extreme( + ticks[..=index] + .iter() + .filter(|t| { + let tt = t.time_ms as i64; + tt >= from && tt <= due && t.price > 0.0 + }) + .map(|t| f64::from(t.price)), + ); + if let Some(peak) = peak { + let next = peak + (fill.price - peak) * params.pump_move_pct / 100.0; + place(due, next, &mut core_line, &mut pending); + } + continue; + } + let Some(due) = pd_due else { + break; + }; let next = if params.price_down_relative { core_line - (core_line - fill.price) * params.price_down_pct / 100.0 } else { @@ -344,10 +427,19 @@ pub fn walk_held( let next = side.farther(next, pd_floor); if (next - core_line).abs() <= f64::EPSILON * core_line.abs() { pd_next = None; - break; + continue; } - place(due, next, &mut core_line, &mut pending); - pd_next = Some(due + step_ms(params.price_down_delay_s)); + // The core times the next step from this one's going through (`Deal::step_lag_ms`); + // a step rounding kept in place sent nothing and waits for nothing — over the + // archived GateF lines two delays with such a step between them run 32 ms over, + // against 47 ms for one real step. + let moved = place(due, next, &mut core_line, &mut pending); + let lag_ms = if moved { + deal.step_lag_ms.max(0.0) as i64 + } else { + 0 + }; + pd_next = Some(due + step_ms(params.price_down_delay_s) + lag_ms); } // SellLevel: to the high of the look-back, adjusted. while let Some(due) = sl_next.filter(|due| t_ms >= *due) { diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index 2aef6938..a621419a 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -41,6 +41,7 @@ fn deal(short: bool) -> Deal { archived_take: None, hook_depth_pct: None, hook_stated_take_pct: None, + step_lag_ms: 0.0, } } @@ -141,10 +142,11 @@ fn price_down_steps_the_line_toward_the_buy_on_the_timer() { } #[test] -fn the_placed_level_is_rounded_to_the_step_and_the_chain_is_not() { +fn the_placed_level_is_rounded_to_the_step() { // ARX, 2026-09-21, step 0.0001: take 0.197802 goes to the book as 0.1978; the 20 % - // relative steps chain off the exact values (0.19714 → 0.196612, not off 0.1971), and the - // floor at +1 % (0.196445) is placed at 0.1964 — which is why the print AT 0.1964 sells. + // relative steps chain off the placed prices (0.1978 → 0.19714 → 0.1971 → 0.19658), and + // the floor at +1 % (0.196445) is placed at 0.1964 — which is why the print AT 0.1964 + // sells. let p = ExitParams { price_down_timer_s: 3.0, price_down_pct: 20.0, @@ -531,3 +533,211 @@ fn verify_holds_the_line_against_the_archived_points() { let w = ExitModel::new(&p).walk(&d, &ticks, fill()); assert_eq!(w.points.len(), 3); } + +// ---- the core's sell price across a step -------------------------------------------------------- + +/// Relative PriceDown on the grid, flat tape under the line: `(t, level)` of every point. +fn grid_walk(take: f64, step_lag_ms: f64, ticks: &[Tick]) -> Vec<(i64, f64)> { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 10.0, + price_down_delay_s: 1.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.1, + ..params() + }; + let mut d = deal(false); + d.buy_price = 1_000.0; + d.tick = Some(1.0); + d.step_lag_ms = step_lag_ms; + let fill = Fill { + t_ms: 0, + price: 1_000.0, + }; + walk(&d, ticks, fill, take, &p) + .points + .iter() + .map(|pt| (pt.t_ms, pt.price)) + .collect() +} + +/// A step that moved the order chains the next one off the ORDER's price: 1096 → 1086.4, placed +/// 1086; then 1086 → 1077.4, placed 1077 — off the exact 1086.4 it would round to 1078. INDEX +/// 2026-09-22 (Gate, 10 % relative): all twelve archived levels land only this way. +#[test] +fn a_moved_order_chains_off_its_placed_price() { + let ticks = tape(&[(500, 1_000.0), (1_500, 1_000.0), (2_500, 1_000.0)]); + assert_eq!( + grid_walk(1_096.0, 0.0, &ticks), + vec![(0, 1_096.0), (1_000, 1_086.0), (2_000, 1_077.0)] + ); +} + +/// A step that rounds back onto the order sends nothing and carries its exact value into the +/// next: 1004 → 1003.6 (still 1004), → 1003.24 (1003), → 1002.7 (still 1003), → 1002.43 (1002). +/// Off the placed price alone the line would never leave 1004; off the exact chain the second +/// move would come a step late. FATCOIN 2026-09-22 climbs one tick every other step this way. +#[test] +fn a_step_rounding_back_onto_the_order_carries_its_exact_value() { + let ticks = tape(&[ + (500, 1_000.0), + (1_500, 1_000.0), + (2_500, 1_000.0), + (3_500, 1_000.0), + (4_500, 1_000.0), + ]); + assert_eq!( + grid_walk(1_004.0, 0.0, &ticks), + vec![(0, 1_004.0), (2_000, 1_003.0), (4_000, 1_002.0)] + ); +} + +/// The core's own replace lag spaces the steps: the first is timed off the take by +/// `PriceDownTimer`, the next one after a step that moved the order off that step plus the delay +/// plus the lag — here every step moves the order. +#[test] +fn the_core_step_lag_spaces_the_price_down_steps() { + let ticks = tape(&[ + (500, 1_000.0), + (1_500, 1_000.0), + (2_500, 1_000.0), + (3_500, 1_000.0), + ]); + let times: Vec = grid_walk(1_096.0, 50.0, &ticks) + .iter() + .map(|&(t, _)| t) + .collect(); + assert_eq!(times, vec![0, 1_000, 2_050, 3_100]); +} + +/// A step rounding kept in place sent nothing, so the next one waits for no round trip: the +/// lag follows only the steps that moved the order (1004 stays at 1 s, 1003 at 2 s, 1003 stays +/// at 3.05 s, 1002 at 4.05 s). +#[test] +fn a_step_kept_in_place_adds_no_lag() { + let ticks = tape(&[ + (500, 1_000.0), + (1_500, 1_000.0), + (2_500, 1_000.0), + (3_500, 1_000.0), + (4_500, 1_000.0), + ]); + assert_eq!( + grid_walk(1_004.0, 50.0, &ticks), + vec![(0, 1_004.0), (2_000, 1_003.0), (4_050, 1_002.0)] + ); +} + +// ---- PumpMove ------------------------------------------------------------------------------ + +/// `PumpMoveTimer` after the take, once: to `PumpMovePersent` of the peak-to-buy distance short +/// of the pump's peak — which printed before the buy. Peak 110, buy 100, 1 %: 109.9. +#[test] +fn the_pump_move_goes_once_to_the_peak_less_its_share() { + let p = ExitParams { + pump_move_timer_s: 2.0, + pump_move_pct: 1.0, + ..params() + }; + let mut d = deal(false); + d.kind = "PumpsDetection".into(); + d.tick = Some(0.01); + let ticks = tape(&[ + (-3_000, 104.0), + (-50, 110.0), + (500, 101.0), + (1_500, 102.0), + (2_600, 101.5), + (6_000, 101.0), + ]); + let w = walk(&d, &ticks, fill(), 104.0, &p); + let points: Vec<(i64, f64)> = w.points.iter().map(|pt| (pt.t_ms, pt.price)).collect(); + assert_eq!(points.len(), 2, "{points:?}"); + assert_eq!(points[1].0, 2_000 + PUMP_MOVE_LAG_MS); + assert!((points[1].1 - 109.9).abs() < 1e-9, "{points:?}"); +} + +// ---- the verdict's clock --------------------------------------------------------------------- + +/// PriceDown 50 % relative every second, 100 ms to the book: the take and its steps. +fn clock_params(take_pct: f64) -> ExitParams { + ExitParams { + sell_price_pct: take_pct, + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_allowed_drop_pct: 0.1, + latency_ms: 100.0, + ..params() + } +} + +/// The core stepped onto 100.5 at 900 ms and sold there 100 ms before the close — the archive +/// files the step and the fill as one point. The model's step to 100.5 is stamped 1 100 ms: late +/// by 200 ms, inside the point tolerance. On the model's own clock the fill found the take still +/// standing and the verdict had nothing to say; on the archive's the line stood at 100.5. +#[test] +fn the_level_at_the_fill_is_read_on_the_archive_clock() { + let mut d = deal(false); + d.sell_price = 100.5; + d.close_ms = 1_000; + let ticks = tape(&[(0, 100.0), (500, 100.0), (1_500, 100.0)]); + let archived = [(0, 101.0), (900, 100.5)]; + let v = verify( + &d, + &ticks, + &EntryParams::Fact, + &clock_params(1.0), + None, + Some(&archived), + ); + assert_eq!(v.exit_kind, Some(ExitKind::Line), "{v:?}"); + assert_eq!(v.exit, Some(true), "{v:?}"); +} + +/// A step the model took with no archived move to match: well before the fill it is a step the +/// core never took, and its level is the answer; inside the point tolerance of the fill it is +/// the model's timing, and the core's last level is. +#[test] +fn a_stray_step_counts_only_outside_the_point_tolerance_of_the_fill() { + // Take 102; the model steps to 101 (1 100 ms), 100.5 (2 100 ms), 100.25 (3 100 ms). The + // core stepped once, to 101, and filled a touch above it. + let mut d = deal(false); + d.sell_price = 101.02; + let ticks = tape(&[ + (0, 100.0), + (500, 100.0), + (1_500, 100.0), + (2_500, 100.0), + (3_500, 100.0), + ]); + let p = clock_params(2.0); + // Filled at 3 450 ms: the model's 100.5 at 2 100 ms is more than a second before it. + d.close_ms = 3_600; + let late = [(0, 102.0), (1_000, 101.0), (3_450, 101.02)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&late)); + assert_eq!(v.exit, Some(false), "{v:?}"); + // Filled at 2 500 ms: the model's 100.5 came 400 ms before it. + d.close_ms = 2_600; + let soon = [(0, 102.0), (1_000, 101.0), (2_500, 101.02)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&soon)); + assert_eq!(v.exit, Some(true), "{v:?}"); +} + +/// The fact's sell timers run from the take the core placed — the archive's first point, less +/// `SellDelay` — when that is later than the report's buy stamp. +#[test] +fn the_fact_sell_starts_at_the_archived_take() { + let d = deal(false); + let p = params(); + let start = + |points: Option<&[(i64, f64)]>, p: &ExitParams| verify::fact_sell_start(&d, p, points).t_ms; + assert_eq!(start(Some(&[(2_000, 101.0), (3_000, 100.5)]), &p), 2_000); + let delayed = ExitParams { + sell_delay_ms: 500.0, + ..params() + }; + assert_eq!(start(Some(&[(2_000, 101.0)]), &delayed), 1_500); + assert_eq!(start(Some(&[(-7, 101.0)]), &p), 0, "never before the buy"); + assert_eq!(start(None, &p), 0); +} diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 4aaa4a79..3d88fe9c 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -25,6 +25,7 @@ use crate::feed::types::Tick; use crate::market::trade_replay::Coverage; +pub mod calibrate; pub mod deals; pub mod entry; pub mod exit; @@ -213,6 +214,10 @@ pub struct Deal { /// a variant asks about a level the core never used, and this number answers only for the /// one it did. pub hook_stated_take_pct: Option, + /// How much later than `PriceDownDelay` the deal's CORE makes the next PriceDown step after + /// one that moved the order, milliseconds — its replace round trip, calibrated by the caller + /// off the core's own archived lines ([`calibrate`]); 0 runs the steps on the plain schedule. + pub step_lag_ms: f64, } impl Deal { diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 8bee1efc..65f72630 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -380,6 +380,10 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "UseStopLoss", "FastStopLoss", "StopLossEMA", + // PumpsDetection's one sell move (see `line::PUMP_MOVE_LAG_MS`); `PumpMovePersent` is the + // core's own spelling of the field. + "PumpMoveTimer", + "PumpMovePersent", "Add1minDelta", "Add5minDelta", "Add15minDelta", @@ -543,6 +547,8 @@ pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { sell_shot_allowed_up_pct: v.num("SellShotAllowedUp", base.sell_shot_allowed_up_pct), sell_shot_allowed_down_pct: v.num("SellShotAllowedDown", base.sell_shot_allowed_down_pct), sell_shot_delay_s: v.num("SellShotDelay", base.sell_shot_delay_s), + pump_move_timer_s: v.num("PumpMoveTimer", base.pump_move_timer_s), + pump_move_pct: v.num("PumpMovePersent", base.pump_move_pct), // `StopLoss` means nothing with `UseStopLoss` off (param_deps.toml: every stop field // hangs on it), and the value stays in the dump when the switch goes off. A dump that // omits the switch keeps the stop, as the model did before it read the switch: 2 of diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 5a26ecdc..b315a4c7 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -41,6 +41,7 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { archived_take: None, hook_depth_pct: None, hook_stated_take_pct: None, + step_lag_ms: 0.0, }; let t0 = deal.buy_ms; let ticks: Vec = vec![ diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index ac0da2d8..91c9267f 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -24,6 +24,7 @@ fn deal(pnl: f64, spent: f64) -> Deal { archived_take: None, hook_depth_pct: None, hook_stated_take_pct: None, + step_lag_ms: 0.0, } } diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index bde04320..28f6726a 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -51,6 +51,7 @@ fn deal() -> Deal { archived_take: None, hook_depth_pct: None, hook_stated_take_pct: None, + step_lag_ms: 0.0, } } @@ -1157,6 +1158,7 @@ fn hook_deal() -> Deal { buy_price: 100.0, hook_depth_pct: Some(4.0), hook_stated_take_pct: Some(2.0), + step_lag_ms: 0.0, ..deal() } } diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 53955995..950115d8 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -17,6 +17,7 @@ use std::time::Duration; use rusqlite::{Connection, OpenFlags}; +use super::super::calibrate; use super::super::exit::ExitModel; use super::super::mshot::DEFAULT_LATENCY_MS; use super::super::params::{StrategyValues, exit_params, mshot_params, param_keys}; @@ -69,6 +70,139 @@ fn held_ticks(exchange_key: &str, market: &str, spans: &Coverage) -> (Vec, .unwrap_or_default() } +/// One deal as the verdict saw it, for an analysis outside the probe: a JSON line in +/// `/deals.jsonl` (the row, the strategy's raw values, the held walk's points, the archived +/// Exit line) and its prints as `t,price,qty,side` in `/ticks/.csv`. +fn dump_deal( + dir: &str, + deal: &Deal, + values: &HashMap, + ticks: &[Tick], + held: &super::super::line::LineWalk, + exit_points: Option<&[(i64, f64)]>, + entry_points: Option<&[(i64, f64)]>, +) { + use std::io::Write; + let dir = PathBuf::from(dir); + let _ = std::fs::create_dir_all(dir.join("ticks")); + let row = serde_json::json!({ + "uid": deal.report_uid, + "core": deal.core_name, + "coin": deal.coin, + "kind": deal.kind, + "short": deal.is_short, + "buy_ms": deal.buy_ms, + "close_ms": deal.close_ms, + "buy": deal.buy_price, + "sell": deal.sell_price, + "reason": deal.sell_reason, + "tick": deal.tick, + "values": values, + "held_exit": [held.exit.t_ms, held.exit.price, format!("{:?}", held.exit.kind)], + "held_points": held.points.iter().map(|p| (p.t_ms, p.price)).collect::>(), + "archive": exit_points, + "entry": entry_points, + }); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("deals.jsonl")) + { + let _ = writeln!(f, "{row}"); + } + let mut csv = String::with_capacity(ticks.len() * 32); + for t in ticks { + let side = if t.side == crate::feed::types::Side::Buy { + 'B' + } else { + 'S' + }; + csv.push_str(&format!( + "{},{},{},{side}\n", + t.time_ms as i64, t.price, t.qty + )); + } + let _ = std::fs::write( + dir.join("ticks").join(format!("{}.csv", deal.report_uid)), + csv, + ); +} + +/// Each core's exchange key — `:`, the tape's own spelling — off the +/// `core N «name» identity: … -> ExchangeId { code: C, dex: D }` lines the application logs when +/// a core connects, since the report does not carry the venue and no core is connected here. +/// Reading a coin under every exchange that stores it instead mixed venues into one tape (AKE +/// sat under five), and judged a deal of a venue without a tape of its own — BB1 is Bybit, and +/// its tape is not in the store — on another venue's prints. Guessing the venue from where the +/// entry fill printed does not work either: a liquid coin prints the same price on Binance and +/// Bybit within a second, and BB1 "voted" Binance 49 to 36. +fn core_venues() -> HashMap { + let mut out = HashMap::new(); + let Ok(dir) = std::fs::read_dir(paths::logs_dir_no_create()) else { + return out; + }; + for entry in dir.flatten() { + let Ok(text) = std::fs::read_to_string(entry.path()) else { + continue; + }; + for line in text + .lines() + .filter(|l| l.contains("identity: exchange_code=")) + { + let core = line + .split_once("core ") + .and_then(|(_, rest)| rest.split_whitespace().next()) + .and_then(|n| n.parse::().ok()); + let id = line + .split_once("ExchangeId { code: ") + .and_then(|(_, rest)| { + let (code, rest) = rest.split_once(", dex: ")?; + let dex = rest.split_once(' ')?.0; + Some((code.parse::().ok()?, dex.parse::().ok()?)) + }); + if let (Some(core), Some((code, dex))) = (core, id) { + out.insert(core, format!("{code}:{dex:08x}")); + } + } + } + out +} + +/// Each core's PriceDown step lag off its own archived Exit lines, the way the axis calibrates +/// it (`calibrate::step_lag_samples` over the deals it loaded, the median per core). +fn core_step_lags( + deals: &[Deal], + keys: &[String], + defaults: &HashMap, +) -> HashMap { + let mut samples: HashMap> = HashMap::new(); + for deal in deals { + if !is_tunable(&deal.kind, &deal.sell_reason) { + continue; + } + let (_, Some(points)) = archived_lines(deal) else { + continue; + }; + let Some(values) = + strategy_values_at(deal.strategy_id, Some(deal.core_uid), deal.buy_ms, keys) + else { + continue; + }; + let exit = exit_params(&StrategyValues { + values: &values, + defaults, + }); + samples + .entry(deal.core_uid) + .or_default() + .extend(calibrate::step_lag_samples(deal, &exit, &points)); + } + samples + .into_iter() + .filter_map(|(core, mut v)| calibrate::median_step_lag(&mut v).map(|lag| (core, lag))) + .collect() +} + fn round3(v: Option) -> Option { v.map(|d| (d * 1000.0).round() / 1000.0) } @@ -107,8 +241,8 @@ fn real_data_reproduction() { read.without_ms ); - // Every (exchange, market) pair the tape holds — the deal's exchange key is not in the - // report, so a coin is tried under each exchange and market spelling that stores it. + // Every (exchange, market) pair the tape holds — the deal's market spelling is not in the + // report, so a coin is tried under each market of its core's exchange that stores it. let spans_db = Connection::open_with_flags(paths::trades_db_path(), OpenFlags::SQLITE_OPEN_READ_ONLY) .expect("trades.sqlite"); @@ -120,9 +254,13 @@ fn real_data_reproduction() { .flatten() .collect(); let margin_ms = crate::market::trade_replay::model_margin_ms(); + let venue_of_core = core_venues(); + eprintln!("core venues (from the identity lines of the logs): {venue_of_core:?}"); let keys = param_keys(); let defaults = HashMap::new(); + let core_lags = core_step_lags(&read.deals, &keys, &defaults); + eprintln!("PriceDown step lag per core: {core_lags:?}"); let (mut entry_hits, mut entry_n, mut exit_hits, mut exit_n, mut with_tape) = (0, 0, 0, 0, 0); let mut kinds_seen: HashMap = HashMap::new(); for mut deal in read.deals { @@ -152,9 +290,15 @@ fn real_data_reproduction() { let spans = window.focus_spans(); let mut ticks: Vec = Vec::new(); let mut covered = Coverage::none(); + // The core's own venue only: a coin the tape holds under several exchanges is not one + // tape, and the axis reads the deal's own (`RowAddress::exchange_key`). A core the logs + // never named is skipped rather than replayed on a mixture. + let Some(venue) = venue_of_core.get(&deal.core_uid) else { + continue; + }; for (exchange, market) in pairs .iter() - .filter(|(_, m)| coin_match_key(coin_of_market(m)) == coin_key) + .filter(|(e, m)| e == venue && coin_match_key(coin_of_market(m)) == coin_key) { let (held, held_covered) = held_ticks(exchange, market, &spans); ticks.extend(held); @@ -180,6 +324,7 @@ fn real_data_reproduction() { EntryParams::Fact }; let exit = exit_params(&sv); + deal.step_lag_ms = core_lags.get(&deal.core_uid).copied().unwrap_or(0.0); deal.pre_spike_ask = archived_pre_spike_ask(exit_points.as_deref(), &exit, deal.is_short); deal.archived_take = archived_take(exit_points.as_deref()); // The modelled line beside the archive's moves, for the eye. @@ -207,12 +352,20 @@ fn real_data_reproduction() { take_from_archive: true, ..exit.clone() }; - let fact_fill = Fill { - t_ms: deal.buy_ms, - price: deal.buy_price, - }; + let fact_fill = verify::fact_sell_start(&deal, &exit, exit_points.as_deref()); let held = ExitModel::new(&fact_exit).walk_held(&deal, &ticks, fact_fill, deal.close_ms); + if let Ok(dir) = std::env::var("MOON_TICKS_DUMP") { + dump_deal( + &dir, + &deal, + &values, + &ticks, + &held, + exit_points.as_deref(), + entry_line.as_deref(), + ); + } eprintln!( " held exit {:?} at {:+}ms of close · stop {:.3}% · model pts {}", held.exit.kind, @@ -277,16 +430,21 @@ fn real_data_reproduction() { .map(|d| super::super::hook::hook_take_pct(d, exit.hook_sell_level_pct)) ), ); - let best = if entry_line.is_some() { - archived + // Counted as the axis counts it (`load.rs::replay_row_with` passes both archived lines + // whenever it has them): the entry off its archived line when there is one — without it + // the two verdicts are the same call — and the exit ALWAYS against the archived Exit + // line. Taking the plain verdict's exit for a deal without an Entry line judged it with + // no archive at all, which is not what the table shows. + let entry_verdict = if entry_line.is_some() { + archived.entry } else { - plain + plain.entry }; - if let Some(ok) = best.entry { + if let Some(ok) = entry_verdict { entry_n += 1; entry_hits += usize::from(ok); } - if let Some(ok) = best.exit { + if let Some(ok) = archived.exit { exit_n += 1; exit_hits += usize::from(ok); } diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index e3c6fe3a..84d23932 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -139,11 +139,9 @@ pub fn verify( // have yet (phase 2), and it stays unanswered until it does. // The exit group is judged from the FACTUAL entry, whatever the entry group modelled: the // sell rules measure from the buy, and a fill the model placed a few ticks off would shift - // every level of a correctly reproduced line. The entry group has its own verdict above. - let fact_fill = Fill { - t_ms: deal.buy_ms, - price: deal.buy_price, - }; + // every level of a correctly reproduced line — and their timers from the moment the core + // placed the take (`fact_sell_start`). The entry group has its own verdict above. + let fact_fill = fact_sell_start(deal, exit, exit_points); // The line is walked HELD through the close: its levels are what is judged, and a print // that would have sold the model's line earlier is the queue's business, not the rule's. // A kind without a take rule of its own starts at the core's archived take. @@ -160,6 +158,18 @@ pub fn verify( // by then, the model's own latency allowed for — and the rule is the take when no move // had reached the exchange, the moving line otherwise. let fact_stopped = reason_starts_with(deal.sell_reason.trim(), REASON_STOP); + // The archive's own record of the sell: its moves, and the fill it filed as a point. + let archive = exit_points + .filter(|p| !p.is_empty()) + .map(|archived| ArchivedExit::of(deal, exit, archived)); + // When the fact filled: the archive's own record of the fill when it filed one, else the + // close. The report books the close when the core does, which can be seconds after the + // fill — FATCOIN 2026-09-22 filled 15 ms after the line's third move and closed 1.8 s later, + // long enough for the model's line to take a fourth step the core's never took. + let filled_at = archive + .as_ref() + .and_then(|a| a.fill) + .map_or(deal.close_ms, |(t, _)| t.min(deal.close_ms)); let closed = match walked.exit.kind { ExitKind::Stop if walked.exit.t_ms <= deal.close_ms + POINT_TIME_TOLERANCE_MS || fact_stopped => @@ -167,21 +177,25 @@ pub fn verify( walked.exit } _ => { - // A point the model stamps up to its own latency after the close is a move due - // before it — the core's stamp is its moment, the model's the print plus latency. - let mut placed: Vec<&LinePoint> = walked - .points - .iter() - .filter(|p| p.t_ms <= deal.close_ms + exit.latency_ms.max(0.0) as i64) - .collect(); // In time order: the take is stamped when it is armed, after any timer step // that fell due inside the sell delay. - placed.sort_by_key(|p| p.t_ms); - match placed.last() { + let mut modelled: Vec<&LinePoint> = walked.points.iter().collect(); + modelled.sort_by_key(|p| p.t_ms); + // A point the model stamps up to its own latency after the fill is a move due + // before it — the core's stamp is its moment, the model's the print plus latency. + let horizon = filled_at + exit.latency_ms.max(0.0) as i64; + let level = archive + .as_ref() + .and_then(|a| level_on_archive_clock(&modelled, a, horizon)) + .or_else(|| modelled.iter().rev().find(|p| p.t_ms <= horizon).copied()); + match level { Some(level) => Exit { t_ms: deal.close_ms, price: level.price, - kind: if placed.len() > 1 { + kind: if modelled + .first() + .is_some_and(|first| first.t_ms < level.t_ms) + { ExitKind::Line } else { ExitKind::Take @@ -222,20 +236,11 @@ pub fn verify( let better = better_by(d); better > 0.0 && better <= FILL_IMPROVEMENT_TOLERANCE * 100.0 }; - let mut archived_fill: Option<(i64, f64)> = None; - let mut archived_level: Option<(i64, f64)> = None; - let points = exit_points.filter(|p| !p.is_empty()).map(|archived| { - let mut moves = archived_replacements(archived); - if moves.len() > 1 - && moves - .last() - .is_some_and(|&last| is_fill_point(deal, exit, last, moves[moves.len() - 2])) - { - archived_fill = moves.pop(); - } - archived_level = moves.last().copied(); - (matched_points(&walked.points, &moves), moves.len()) - }); + let archived_fill = archive.as_ref().and_then(|a| a.fill); + let archived_level = archive.as_ref().and_then(|a| a.moves.last().copied()); + let points = archive + .as_ref() + .map(|a| (matched_points(&walked.points, &a.moves), a.moves.len())); let line_ok = points.is_none_or(|(matched, total)| matched == total); let corroborated = points.is_some_and(|(matched, total)| matched == total); // A level placed THROUGH the market: the archive filed the fill as a point of its own @@ -275,6 +280,126 @@ pub fn verify( } } +/// The entry the fact's sell rules count from: the buy price, at the moment the core placed +/// its take — the archived Exit line's first point, less `SellDelay` — when that is later than +/// `buydatems`, else at `buydatems`. +/// +/// The report stamps the buy at its first fill; the core starts the sell, and every timer of +/// it, when it books the buy done. On the live sample (2026-09-22) 32 archived lines placed the +/// take more than 0.3 s after `buydatems` — up to 32 s, a limit buy filling in parts — and on +/// every one from 0.5 s up the first PriceDown step came `PriceDownTimer` after the TAKE, not +/// after the buy (MORPHO: take +2 135 ms, first step +32 155 ms on a 30 s timer). Below half a +/// second both happen — a Spread's take stamped 317 ms late still stepped off the buy — and +/// holding the take-anchored reading back under a threshold of 0.5 s cost two verdicts more than +/// it saved on the same sample. +/// +/// `SellDelay` keeps the relation the walk already has: the timers run from the booked buy and +/// the take goes up `SellDelay` after it. Every one of the 1 613 live deals ran it at 0, so which +/// moment the core's timers count from when it is not is unchecked. +/// +/// Args: +/// deal: The report row. +/// exit: The parameters, for `SellDelay`. +/// exit_points: The archived Exit line, when the archive holds it. +pub fn fact_sell_start(deal: &Deal, exit: &ExitParams, exit_points: Option<&[(i64, f64)]>) -> Fill { + let booked = exit_points + .and_then(|points| points.first()) + .map(|&(t, _)| t - exit.sell_delay_ms.max(0.0) as i64); + Fill { + t_ms: booked.map_or(deal.buy_ms, |t| t.max(deal.buy_ms)), + price: deal.buy_price, + } +} + +/// An archived Exit line as the verdict reads it: the moves the core made with its sell, and +/// the fill when the archive filed it as a point of its own ([`is_fill_point`]). +pub(super) struct ArchivedExit { + pub(super) moves: Vec<(i64, f64)>, + pub(super) fill: Option<(i64, f64)>, +} + +impl ArchivedExit { + pub(super) fn of(deal: &Deal, exit: &ExitParams, archived: &[(i64, f64)]) -> Self { + let mut moves = archived_replacements(archived); + let fill = match moves.as_slice() { + [.., prev, last] if is_fill_point(deal, exit, *last, *prev) => moves.pop(), + _ => None, + }; + Self { moves, fill } + } +} + +/// The modelled level at the fill read on the ARCHIVE's clock: when the model re-placed at +/// every archived move, the level is the model's own point for the core's last move before the +/// fill — unless the model moved again, with no archived move to match, more than +/// [`POINT_TIME_TOLERANCE_MS`] before `horizon`: that is a step the core never took, and its +/// level is what the model is held to. `None` — read the model's own clock instead — when a +/// move went unmatched. +/// +/// The fill point counts as the core's last move when the model made that move too: a line +/// that stepped onto the level it then filled at is filed as ONE point — the step and the fill +/// at the same price, which [`is_fill_point`] reads as the fill because it came within the +/// latency of the close (AKE 2026-09-22: the fourth step 94 ms before the close, sold at it). +/// +/// The model's timing is good to the point tolerance and no better, and the level at the fill +/// is the one place the verdict read it to the millisecond: a fill 15 ms after the core's move +/// (a level placed through the market) failed whenever the model stamped that same move a +/// little later, and a step the model took a few hundred ms before a fill the core took before +/// ITS step failed the other way. On the live sample (2026-09-22) timing the steps by each +/// core's own replace lag won 48 verdicts and lost 35 of exactly these two shapes. +/// +/// Args: +/// modelled: The model's points, in time order. +/// archive: The archived line — its moves and its fill point. +/// horizon: The fill, plus the model's own latency. +fn level_on_archive_clock<'a>( + modelled: &[&'a LinePoint], + archive: &ArchivedExit, + horizon: i64, +) -> Option<&'a LinePoint> { + let same = |m: &LinePoint, (t, p): (i64, f64)| { + (m.t_ms - t).abs() <= POINT_TIME_TOLERANCE_MS + && deviation_pct(m.price, p).is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0) + }; + // A line the archive holds as its take alone says nothing about when the core stepped + // (MUSEBOOK 2026-09-22 — "Auto Price Down", the archive one point long, sold 1.2 s after + // the take); a take and a fill point is a line, the step filed as the fill. + let filed = archive.moves.len() + usize::from(archive.fill.is_some()); + if filed < 2 || matched_points_of(modelled, &archive.moves) != archive.moves.len() { + return None; + } + let own_of = |mv: (i64, f64)| { + modelled + .iter() + .filter(|m| same(m, mv)) + .min_by_key(|m| (m.t_ms - mv.0).abs()) + .copied() + }; + // The latest archived point before the fill the model re-placed at: the fill point when + // the model made that move too, the last move otherwise (matched, as every move is here). + let own = archive + .moves + .iter() + .chain(archive.fill.iter()) + .rev() + .filter(|(t, _)| *t <= horizon) + .find_map(|&mv| own_of(mv))?; + let stray = modelled + .iter() + .rev() + .find(|m| { + m.t_ms > own.t_ms + && m.t_ms <= horizon - POINT_TIME_TOLERANCE_MS + && !archive + .moves + .iter() + .chain(archive.fill.iter()) + .any(|&mv| same(m, mv)) + }) + .copied(); + Some(stray.unwrap_or(own)) +} + /// Whether the archive's last move is the FILL filed as a point rather than a move of the /// line: at the price the core sold at, and either within the model's latency of the close /// (GUN 2026-09-21: 31 ms before it) or on the fill side of the level before it — a limit @@ -436,6 +561,12 @@ pub fn archived_replacements(points: &[(i64, f64)]) -> Vec<(i64, f64)> { /// How many archived moves the modelled line re-placed at, within the tolerances. fn matched_points(modelled: &[LinePoint], archived: &[(i64, f64)]) -> usize { + let modelled: Vec<&LinePoint> = modelled.iter().collect(); + matched_points_of(&modelled, archived) +} + +/// [`matched_points`] over borrowed points. +fn matched_points_of(modelled: &[&LinePoint], archived: &[(i64, f64)]) -> usize { archived .iter() .filter(|&&(t, p)| { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index 56ffffc4..1ff1d27c 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -306,13 +306,7 @@ impl AnalyticsView { job::JobEvent::Started(_) => {} job::JobEvent::Row(answer) => { let uid = answer.deal.report_uid; - self.ticks.update_row(uid, |slot| { - slot.tape = answer.tape; - slot.verdict = answer.verdict; - slot.deal.tick = answer.deal.tick; - slot.ticks = answer.ticks; - slot.entry_line = answer.entry_line; - }); + self.ticks.update_row(uid, |slot| slot.take_replay(*answer)); // A row joined the replayable set: the variant columns are due a rescore. self.arm_ticks_variants(cx); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs new file mode 100644 index 00000000..8133209f --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs @@ -0,0 +1,80 @@ +//! Each core's PriceDown step lag, calibrated off the archived Exit lines of the rows an axis +//! load read, and held for the whole process: every path that replays a row — the load, the +//! fetch job, the startup autoload — reads it from here, so a row replayed anywhere steps on +//! the clock the last load found for its core. Only the load calibrates — a fetch walks one +//! cluster at a time, too few lines to take a median from — so a replay before the first load +//! of the axis in this process runs its core on the plain schedule; the load's own stage C +//! replays every row after calibrating, which is what the table and the variants then read. +//! The calibration itself is `moon_core::db::tuner::ticks::calibrate`. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use moon_core::db::tuner::strategy_values_at; +use moon_core::db::tuner::ticks::{calibrate, params}; + +use super::load::ArchivedLines; +use super::state::DealRow; + +/// Core uid → its step lag, milliseconds. +static LAGS: OnceLock>> = OnceLock::new(); + +fn lags() -> std::sync::MutexGuard<'static, HashMap> { + LAGS.get_or_init(Default::default) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Calibrate every core of `rows` off the archived Exit lines in `traces`, each row on the +/// strategy parameters as of its buy. A core with too few samples in these rows keeps what an +/// earlier load found for it; a core never calibrated steps on the plain schedule. +/// +/// Args: +/// rows: The rows of the load. +/// traces: Their archived lines, by `reportuid`. +/// defaults: The strategy-field defaults of the live schema. +pub(super) fn calibrate_from( + rows: &[DealRow], + traces: &HashMap, + defaults: &HashMap, +) { + let keys = params::param_keys(); + let mut samples: HashMap> = HashMap::new(); + for row in rows { + // Two steps at least, after the take: nothing shorter holds a pair of steps. + let Some(points) = traces + .get(&row.deal.report_uid) + .and_then(|lines| lines.exit_points.as_deref()) + .filter(|points| points.len() >= 3) + else { + continue; + }; + let Some(values) = strategy_values_at( + row.deal.strategy_id, + Some(row.deal.core_uid), + row.deal.buy_ms, + &keys, + ) else { + continue; + }; + let exit = params::exit_params(¶ms::StrategyValues { + values: &values, + defaults, + }); + samples + .entry(row.deal.core_uid) + .or_default() + .extend(calibrate::step_lag_samples(&row.deal, &exit, points)); + } + let mut store = lags(); + for (core, mut core_samples) in samples { + if let Some(lag) = calibrate::median_step_lag(&mut core_samples) { + store.insert(core, lag); + } + } +} + +/// The step lag of `core`, milliseconds; 0 when no load has calibrated it. +pub(super) fn step_lag_of(core: u64) -> f64 { + lags().get(&core).copied().unwrap_or(0.0) +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index adfdb536..0d26fb1d 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -299,6 +299,8 @@ impl AnalyticsView { }) .collect(); let mut traces = archived_lines(&rows); + // Before any row is replayed: each core's step lag, off these rows' archives. + super::lags::calibrate_from(&rows, &traces, &defaults); let now_ms = moon_core::util::now_unix_ms_i64(); for row in &mut rows { let lines = traces.remove(&row.deal.report_uid).unwrap_or_default(); @@ -620,6 +622,8 @@ pub(super) fn replay_row_with( ); row.deal.archived_take = moon_core::db::tuner::ticks::archived_take(lines.exit_points.as_deref()); + // The core's own clock for its PriceDown steps, as the last load calibrated it. + row.deal.step_lag_ms = super::lags::step_lag_of(row.deal.core_uid); row.verdict = Some(verify( &row.deal, &ticks, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index 1e64524e..c7c257cd 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -38,6 +38,7 @@ use state::{DealRow, TapeStatus}; pub(in crate::analytics::tuner) mod columns; pub(crate) mod fetch; mod grid; +mod lags; mod load; pub(in crate::analytics::tuner) mod rows; pub(in crate::analytics) mod state; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 1c54ecd1..2cca7846 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -27,6 +27,7 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { archived_take: None, hook_depth_pct: None, hook_stated_take_pct: None, + step_lag_ms: 0.0, } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 101b41ce..ae5719eb 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -72,6 +72,28 @@ pub(in crate::analytics::tuner) struct DealRow { pub(in crate::analytics::tuner) held: Option<(i64, i64)>, } +impl DealRow { + /// Take everything a replay learned about this row from its answer: the tape's word, the + /// verdict, the model inputs derived for the deal (the price step, the archived pre-spike + /// ask and take, the core's step lag), the prints, the entry line and the held coverage. + /// Every fold of a replay answer goes through here: the variants replay the STORED row + /// (`prepared_deals`), so a take lifted to the archive's pre-spike ask in the verdict but + /// read off the tape in the variants puts the two on different levels, and a row folded + /// without its held coverage reads a trail of 0 and clips every variant tape at its close + /// (`common_horizon_ms` is the shortest trail of the sample). + pub(in crate::analytics::tuner) fn take_replay(&mut self, answer: DealRow) { + self.tape = answer.tape; + self.verdict = answer.verdict; + self.deal.tick = answer.deal.tick; + self.deal.pre_spike_ask = answer.deal.pre_spike_ask; + self.deal.archived_take = answer.deal.archived_take; + self.deal.step_lag_ms = answer.deal.step_lag_ms; + self.ticks = answer.ticks; + self.entry_line = answer.entry_line; + self.held = answer.held; + } +} + /// Where a deal's prints live, as the replay worker keys them, plus what a fetch needs. #[derive(Clone, Debug)] pub(in crate::analytics::tuner) struct RowAddress { @@ -457,12 +479,7 @@ impl TicksState { if stale { continue; } - slot.tape = answer.tape; - slot.verdict = answer.verdict; - slot.deal.tick = answer.deal.tick; - slot.ticks = answer.ticks; - slot.entry_line = answer.entry_line; - slot.held = answer.held; + slot.take_replay(answer); } data.retain_within_cap(); data.refresh_summary(); From 41d280a053d21d692ec53295539ae000d7668a9c Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 09:02:34 +0200 Subject: [PATCH 19/51] feat(tuner): search only the trades the model reproduces, lean variants on the fact, take after the latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/db/tuner/ticks/calibrate/tests.rs | 2 + crates/moon-core/src/db/tuner/ticks/deals.rs | 3 + crates/moon-core/src/db/tuner/ticks/exit.rs | 120 ++++++--- crates/moon-core/src/db/tuner/ticks/line.rs | 49 +++- .../src/db/tuner/ticks/line/tests.rs | 39 +++ crates/moon-core/src/db/tuner/ticks/mod.rs | 21 +- crates/moon-core/src/db/tuner/ticks/params.rs | 32 ++- crates/moon-core/src/db/tuner/ticks/record.rs | 148 +++++++++++ .../src/db/tuner/ticks/record/tests.rs | 242 ++++++++++++++++++ .../src/db/tuner/ticks/search/tests.rs | 21 +- .../src/db/tuner/ticks/stats/tests.rs | 2 + crates/moon-core/src/db/tuner/ticks/tests.rs | 104 +++++++- .../src/db/tuner/ticks/tests/real_data.rs | 64 ++++- crates/moon-core/src/db/tuner/ticks/verify.rs | 123 +++++---- .../src/analytics/tuner/ticks/load.rs | 17 +- .../src/analytics/tuner/ticks/mod.rs | 54 ++-- .../src/analytics/tuner/ticks/rows.rs | 17 +- .../src/analytics/tuner/ticks/rows/tests.rs | 36 ++- .../src/analytics/tuner/ticks/state.rs | 61 +++-- .../src/analytics/tuner/ticks/variants.rs | 10 +- locales/analytics.yml | 48 ++-- 21 files changed, 984 insertions(+), 229 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/record.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/record/tests.rs diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs index aa55514e..44a0f516 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs @@ -27,6 +27,8 @@ fn deal() -> Deal { hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, + stop_anchor: None, + own_entry: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index c15aa8f1..abe71c20 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -213,6 +213,9 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, + // Filled with the model inputs, once the archive and the parameters are in. + stop_anchor: None, + own_entry: None, }); order.push((close_ms, report_uid)); } diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index a184083a..3b73f4b2 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -2,9 +2,11 @@ //! model for every strategy kind — after the entry filled, the exit of any strategy is a //! function of the sell-order rules and the tape. //! -//! The take-profit is `SellPrice` per cent above the fill for every kind but MoonHook, whose -//! take replaces it with `HookSellLevel` per cent of the trade's own detect depth -//! ([`super::hook`]), and both are moved by the Delta-Modifier family (`SellModifier`). It is +//! The take-profit is `SellPrice` per cent above the fill for every kind but two: MoonHook, +//! whose take replaces it with `HookSellLevel` per cent of the trade's own detect depth +//! ([`super::hook`]), and Spread, whose take is the edge of the spread it detected — a level, +//! not a rule, taken as the core recorded it ([`take_is_recorded`]). The rules are moved by the +//! Delta-Modifier family (`SellModifier`). It is //! raised by `MShotSellAtLastPrice` to //! the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the 4-second-old ASK, i.e. before //! the spike"; the model takes the ask the caller recovered from the order archive @@ -61,10 +63,9 @@ pub struct ExitParams { /// (median of 526 that set it), so it rarely binds. pub max_modifier: f64, /// `StopLossModifier` — the same summed modifiers, applied to the STOP instead of the sell: - /// the stop goes DEEPER by `StopLossModifier · Σ`. Taken verbatim from the core's own log - /// line, of which 136 were read on this machine (2026-09-22): - /// `StopLoss adjusted [-2.00% - (0.20*1.86=0.37%) => -2.37% ]` — and the arithmetic of all - /// 136 reproduces exactly. Set on 415 of 1869 live strategies, median 0.3. + /// the stop goes DEEPER by `StopLossModifier · Σ`, as the core's FAQ spells it: + /// `StopLoss adjusted [-1.00% - (10.00*0.98=9.75%) => -10.75%]`. Set on 415 of 1869 live + /// strategies, median 0.3. pub stop_loss_modifier: f64, /// The `Add*Delta` family of the Delta Modifiers tab — the same shape as MoonShot's /// `MShotAdd*` corridor modifiers, different fields: these move the ORDER PRICE, those the @@ -119,6 +120,11 @@ pub struct ExitParams { /// the panic sell). Ignored by a fast stop — the FAQ's own distinction, and the live /// activations agree: 48 fast stops with it at 3 fire as promptly as 81 without it. pub stop_loss_ema: f64, + /// A sell rule the strategy switched on that the model does not have. The walk runs as if + /// it were off, and the verdict answers nothing for such a trade, which keeps it out of the + /// search (`record::fit_for_search`): a variant's exit there is whatever the missing rule + /// would have made of it. + pub unmodelled: Option, /// Model parameter: how long a replacement of the sell takes to reach the book. pub latency_ms: f64, /// Verdict-only: start the line at the archived take (`Deal::archived_take`) for a kind @@ -175,12 +181,22 @@ impl Default for ExitParams { // its absence there is the core's default, NO. fast_stop_loss: true, stop_loss_ema: 0.0, + unmodelled: None, latency_ms: DEFAULT_LATENCY_MS, take_from_archive: false, } } } +/// A sell rule the strategy can switch on that the model does not have. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnmodelledRule { + /// `UseTrailing` — the trailing stop. + Trailing, + /// `UseSecondStop` / `UseStopLoss3` — the stop ladder. + StopLadder, +} + /// The exit model over one parameter set. pub struct ExitModel<'a> { params: &'a ExitParams, @@ -197,9 +213,12 @@ impl<'a> ExitModel<'a> { pub fn take_level(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> f64 { // A kind whose take rule the model does not have starts where the core's line did — // when the fact is being judged; a variant computes the take from the rules for every - // kind (see `ExitParams::take_from_archive`). The archived level is the core's own, - // modifiers and all, so nothing below is added to it. - if self.params.take_from_archive && !take_model_for(&deal.kind) { + // kind (see `ExitParams::take_from_archive`) but the one whose take is not a rule at + // all ([`take_is_recorded`]). The recorded level is the core's own, modifiers and all, + // so nothing below is added to it. + if (self.params.take_from_archive && !take_model_for(&deal.kind)) + || take_is_recorded(&deal.kind) + { if let Some(take) = deal.archived_take.filter(|t| t.is_finite() && *t > 0.0) { return take; } @@ -254,32 +273,40 @@ impl<'a> ExitModel<'a> { modifier_sum(self.params, deal) * self.params.sell_modifier } - /// Whether the model knows where this trade's take stood at all. + /// Whether a walk under these parameters knows where the trade's take stands — the level + /// every PriceDown step and every fill of the line is counted from. /// - /// `SellPrice` is the take of every kind the core has but one, so the answer is `true` - /// unless the trade is a MoonHook — the kind that replaces the field with `HookSellLevel` - /// of its detect depth. A hook answers `true` when the archive hands the level over, or - /// when both the depth and the level are known and `HookSellFixed` is off (the fixed branch - /// computes the distance differently and is not modelled — no live strategy sets it, so it - /// could not be checked against anything). + /// Asked with a VARIANT's parameters, so it answers for the variants: a trade whose take a + /// variant cannot place is one the search cannot run, whatever the fact's own replay did + /// with the level the core recorded. Per kind: /// - /// `false` is not "the model was wrong": it is "this kind's rule is not modelled here", and - /// [`super::verify`] then answers the exit group with nothing rather than judging the line - /// against a level the model invented. Measured on the live sample (2026-09-22): reading - /// this wider — every kind without an archived line — silenced five verdicts that - /// `SellPrice` had answered legitimately, four of them hits. + /// - a MoonHook needs its rule's inputs — the detect depth and `HookSellLevel`, with + /// `HookSellFixed` off (that branch computes the distance differently and is not modelled: + /// no live strategy sets it, so it could not be checked against anything); + /// - a Spread needs the take the core recorded ([`take_is_recorded`]); + /// - a MoonShot lifted to the pre-spike ask (`MShotSellAtLastPrice`) needs that ask off the + /// core's record (`Deal::pre_spike_ask`) — the tape's print before the spike sits 0.1–0.5 % + /// under the book's ask on a dump, and on 88 stopped MoonShot trades (2026-09-23) a take + /// placed off it was touched before the core's stop on 30, turning a loss into a win; + /// - every other kind takes `SellPrice`, known by construction. + /// + /// `false` is not "the model was wrong": it is "this trade's take is not modelled here", and + /// [`super::verify`] then answers the exit group with nothing — which keeps the trade out of + /// the search (`record::fit_for_search`). pub fn take_known(&self, deal: &Deal) -> bool { - if deal.kind != KIND_MOONHOOK { - return true; + let positive = |v: Option| v.is_some_and(|x| x.is_finite() && x > 0.0); + if deal.kind == KIND_MOONHOOK { + return !self.params.hook_sell_fixed + && self.params.hook_sell_level_pct > 0.0 + && positive(deal.hook_depth_pct); + } + if take_is_recorded(&deal.kind) { + return positive(deal.archived_take); } - if deal.archived_take.is_some_and(|t| t.is_finite() && t > 0.0) { - return true; + if take_model_for(&deal.kind) && self.params.sell_at_last_price { + return positive(deal.pre_spike_ask); } - !self.params.hook_sell_fixed - && self.params.hook_sell_level_pct > 0.0 - && deal - .hook_depth_pct - .is_some_and(|d| d.is_finite() && d > 0.0) + true } /// Replay the tape after the fill: the take, the moving line, the stop. @@ -315,14 +342,13 @@ impl<'a> ExitModel<'a> { /// The summed delta modifiers of a trade, capped: `Min(MaxModifier, Σ Pn · Dn)`. /// /// One sum, two consumers — the sell level through `SellModifier` and the stop through -/// `StopLossModifier` — because the core computes it once and spends it on both (FAQ, and the -/// `StopLoss adjusted` log line prints that very number). +/// `StopLossModifier` — because the core computes it once and spends it on both (FAQ). /// -/// What it cannot be: exact. The coefficients are the strategy's, but the deltas are the ONE -/// snapshot the report stores per trade, while the core re-evaluates them live. Checked against -/// 121 of the core's own printed sums (2026-09-22): the structure reproduces them — a quarter -/// land within 0.02 and 57 % within 0.1 — and the residual grows with how long the entry order -/// waited before it filled, which is the deltas moving under a snapshot taken once. +/// Off the report it cannot be exact: the coefficients are the strategy's, but the deltas are +/// the ONE snapshot the report stores per trade, while the core re-evaluates them live. On the +/// stop the verdict absorbs the residual in its level tolerance (`verify::STOP_PRICE_TOLERANCE`); +/// on the sell level it is a limit of the input, and a take it moves too far fails its verdict +/// and keeps the trade out of the search. /// /// Args: /// params: The sell parameters, for the coefficients and the ceiling. @@ -346,9 +372,8 @@ pub fn modifier_sum(params: &ExitParams, deal: &Deal) -> f64 { /// — rather than a level. Clamping it to a hair's breadth from the entry instead would fire on /// the first print that moves, which is not a stop but a coin flip dressed as one; and placing /// it beyond the entry would fire on the first print, full stop. What the core does with an -/// adjustment that large is unknown: no such case appears in the 136 `StopLoss adjusted` lines -/// read off live cores, and none of the 1 735 replayed trades reaches this branch, so the model -/// declines to invent an answer. A configured stop on the profit side (`StopLoss` positive — +/// adjustment that large is unknown: none of the 1 735 replayed trades reaches this branch, so +/// the model declines to invent an answer. A configured stop on the profit side (`StopLoss` positive — /// live data has it) is a different thing and is left exactly as configured. /// /// Args: @@ -366,6 +391,19 @@ pub fn stop_pct(params: &ExitParams, deal: &Deal) -> f64 { adjusted } +/// The kind name of Spread as the strategy list spells it. +pub const KIND_SPREAD: &str = "Spread"; + +/// Whether the kind's take is not a rule of its parameters but a level the core took off the +/// detect — the spread it detected. `SellPrice` places it on 5 of 157 archived Spread takes +/// (2026-09-23) — the coincidences it takes for the width of a spread to land on the field. The +/// report's `comment` keeps only the width, rounded to 0.1 %, so the level is the take the order +/// archive recorded or nothing, for the fact and for every variant alike; and `SellPrice` is no +/// knob of the kind (`params::TICK_PARAMS`). +pub fn take_is_recorded(kind: &str) -> bool { + kind == KIND_SPREAD +} + /// Whether the take the model computes for the kind is the kind's OWN rule, rather than the /// general `SellPrice` — true for MoonShot, whose `MShotSellAtLastPrice` lift belongs to it /// alone. For the rest the verdict prefers the archived level when the trade has one diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs index 597d2904..fe3dd744 100644 --- a/crates/moon-core/src/db/tuner/ticks/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/line.rs @@ -143,6 +143,9 @@ struct BookStop { proxy: Option, avg: Option, next_sample: i64, + /// Up to when the fact proves this stop did not fire (`record::StopAnchor`): a sample by + /// then is averaged but cannot fire. `i64::MIN` when the walk is not the trade's own stop. + quiet_until: i64, } impl BookStop { @@ -160,7 +163,7 @@ impl BookStop { .avg .map_or(bid, |a| self.alpha * bid + (1.0 - self.alpha) * a); self.avg = Some(avg); - if at >= self.armed_at && reaches(avg, self.level, self.long) { + if at >= self.armed_at && at > self.quiet_until && reaches(avg, self.level, self.long) { return Some(Exit { t_ms: at, price: bid, @@ -265,6 +268,9 @@ pub fn walk_held( }; let latency_ms = params.latency_ms.max(0.0) as i64; let armed_at = fill.t_ms + params.sell_delay_ms.max(0.0) as i64; + // When the take is on the book: placed at `armed_at`, there after the same latency as any + // move of the line. + let take_live_at = armed_at + latency_ms; // What the exchange is given: the level on the price grid. let placed = |level: f64| match deal.tick { Some(tick) => round_to_step(level, tick), @@ -368,6 +374,13 @@ pub fn walk_held( let stop_on = stop != 0.0; let stop_level = side.over(fill.price, stop); let stop_from = fill.t_ms + (params.stop_loss_delay_s.max(0.0) * 1000.0) as i64; + // What the fact proves about the stop when this walk runs the trade's own + // (`record::StopAnchor`): it fired when the core's did, at the price the core sold at, and + // not a moment before — nor before the close, on a trade it never stopped. The book the + // stop watches is not on the tape; the fact is the book's own answer. + let anchor = deal.stop_anchor.filter(|a| a.holds(deal, fill, params)); + let quiet_until = anchor.map_or(i64::MIN, |a| a.quiet_until_ms); + let fired = anchor.and_then(|a| a.fired); // The non-fast stop's BID proxy: the last print on the stop's side of the book, sampled on // its own clock and averaged over `StopLossEMA` samples (see `STOP_SAMPLE_MS`). let book_stop = stop_on && !params.fast_stop_loss; @@ -379,7 +392,16 @@ pub fn walk_held( proxy: None, avg: None, next_sample: fill.t_ms + STOP_SAMPLE_MS, + quiet_until, }); + let anchored_stop = |at: i64, price: f64, points: Vec| LineWalk { + exit: Exit { + t_ms: at, + price, + kind: ExitKind::Stop, + }, + points, + }; let mut last_t = fill.t_ms; for (index, tick) in ticks.iter().enumerate() { @@ -389,6 +411,11 @@ pub fn walk_held( continue; } last_t = t_ms; + // The fact's own stop fired between the last print and this one: ahead of anything + // this print does, and after everything the prints before it did. + if let Some((at, sold)) = fired.filter(|(at, _)| t_ms >= *at) { + return anchored_stop(at, sold, points); + } // The timer-driven rules moved the line at their own moments, between prints; every // step due by this print happened BEFORE it, and a step that also reached the book // before it is what this print meets. @@ -485,7 +512,12 @@ pub fn walk_held( } // The fast stop is a market order the core fires on the print; the sell is a limit the // print reaches. Both come before the print-driven rule below moves anything. - if stop_on && !book_stop && t_ms >= stop_from && reaches(price, stop_level, side.long) { + if stop_on + && !book_stop + && t_ms >= stop_from + && t_ms > quiet_until + && reaches(price, stop_level, side.long) + { return LineWalk { exit: Exit { t_ms, @@ -500,8 +532,15 @@ pub fn walk_held( // contracts at the level against a sell of 18 000 and the core's line stood). The // verdict on the fact does not lean on this: `verify` judges the line by where it // STOOD at the close, not by which print the model sold on. + // + // The take is an order like every move, and reaches the book `latency_ms` after the + // core placed it: the spike's own tail, printed in the milliseconds after the fill, is + // not a print the take was there for. Filling on it turned 30 of 88 stopped MoonShot + // trades into wins on the live sample (2026-09-23), the take "touched" 9 ms after the + // buy by the pump it was bought on. let held = hold_until_ms.is_some_and(|until| t_ms <= until); - if t_ms > armed_at && !held && reaches(price, exch_line, !side.long) { + if t_ms > armed_at && t_ms >= take_live_at && !held && reaches(price, exch_line, !side.long) + { return LineWalk { exit: Exit { t_ms, @@ -568,6 +607,10 @@ pub fn walk_held( } } let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(last_t); + // The fact's own stop, past the last print: the tape went quiet, the core did not. + if let Some((at, sold)) = fired { + return anchored_stop(at, sold, points); + } // The book stop's samples up to the tape's end — the one AT the last print included — read // the proxy the last prints left; the loop only ever reaches the samples before a print. if let Some(exit) = book.as_mut().and_then(|book| book.sample_before(tail + 1)) { diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index a621419a..322ba889 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -42,6 +42,8 @@ fn deal(short: bool) -> Deal { hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, + stop_anchor: None, + own_entry: None, } } @@ -464,6 +466,43 @@ fn verify_judges_a_book_stop_by_its_level_and_moment() { assert_eq!(v.exit, Some(false), "{v:?}"); } +/// The take is an order like every move: on the book `latency_ms` after the core placed it. The +/// spike's own tail, printed in the milliseconds after the fill, cannot fill it. +#[test] +fn the_take_is_on_the_book_only_after_the_latency() { + let p = ExitParams { + latency_ms: 100.0, + ..ExitParams::default() + }; + let ticks = tape(&[(9, 101.5), (150, 101.2)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Take, 150)); +} + +/// A market stop's sale sweeps our size through the book: with no level on record — its reason +/// never carries one — the verdict holds the moment it fired, never the sweep's price. +#[test] +fn verify_judges_a_market_stop_without_a_level_by_its_moment() { + let fast = ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: true, + ..params() + }; + let mut d = deal(false); + d.sell_reason = "StopLoss Market Sell".into(); + // 1.4 % past the print that fired it: the sweep, which no print on the tape shows. + d.sell_price = 97.5; + d.close_ms = 3_100; + let ticks = tape(&[(1_000, 99.5), (3_000, 98.9), (5_000, 99.0)]); + let v = verify(&d, &ticks, &EntryParams::Fact, &fast, None, None); + assert_eq!(v.exit_kind, Some(ExitKind::Stop)); + assert_eq!(v.exit, Some(true), "{v:?}"); + assert_eq!(v.exit_dev_pct, None); + d.close_ms = 9_000; + let v = verify(&d, &ticks, &EntryParams::Fact, &fast, None, None); + assert_eq!(v.exit, Some(false), "six seconds off the moment, {v:?}"); +} + // ---- the mirror and the archive ------------------------------------------------------------- #[test] diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 3d88fe9c..c865e6d7 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -33,6 +33,7 @@ pub mod hook; pub mod line; pub mod mshot; pub mod params; +pub mod record; pub mod scope; pub mod search; pub mod stats; @@ -44,6 +45,7 @@ pub use exit::{ExitModel, ExitParams, archived_pre_spike_ask, archived_take, tak pub use hook::{HookDetect, KIND_MOONHOOK, hook_take_pct, parse_hook_detect}; pub use mshot::{MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; +pub use record::{StopAnchor, fit_for_search, prepare_deal}; pub use scope::{is_service_row, is_tunable}; pub use search::{PreparedDeal, SearchParams, SearchResult, suggest, variant_tally}; pub use stats::{fact_stats, stats_of}; @@ -218,6 +220,13 @@ pub struct Deal { /// one that moved the order, milliseconds — its replace round trip, calibrated by the caller /// off the core's own archived lines ([`calibrate`]); 0 runs the steps on the plain schedule. pub step_lag_ms: f64, + /// What the fact proves about the stop, for a variant that runs the same one + /// ([`record::StopAnchor`]); filled with the rest of the model inputs, `None` before. + pub stop_anchor: Option, + /// The entry parameters the trade itself ran with. A variant that runs exactly these has + /// nothing to model on the entry side: the fill is the report's ([`simulate`]). `None` + /// until the model inputs are filled, and in the verdict, which exists to test the model. + pub own_entry: Option, } impl Deal { @@ -350,11 +359,15 @@ pub fn simulate( exit: &ExitParams, entry_line: Option<&[(i64, f64)]>, ) -> Outcome { + let fact_fill = Fill { + t_ms: deal.buy_ms, + price: deal.buy_price, + }; let fill = match entry { - EntryParams::Fact => Some(Fill { - t_ms: deal.buy_ms, - price: deal.buy_price, - }), + EntryParams::Fact => Some(fact_fill), + // The trade's own entry settings filled where the report says; the model is for the + // entries the core never ran. + EntryParams::MoonShot(_) if deal.own_entry.as_ref() == Some(entry) => Some(fact_fill), EntryParams::MoonShot(params) => MshotEntry::new(params).fill(deal, ticks, entry_line), }; let Some(fill) = fill else { diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 65f72630..0869d348 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; -use super::exit::ExitParams; +use super::exit::{ExitParams, UnmodelledRule}; use super::mshot::{Modifiers, MshotParams, UsePrice}; /// Which group of the grid a parameter belongs to. @@ -52,6 +52,9 @@ pub struct TickParam { const MSHOT: &[&str] = &["MoonShot"]; const HOOK: &[&str] = &[super::hook::KIND_MOONHOOK]; +/// The kinds whose take `SellPrice` does not place: MoonHook's is `HookSellLevel`, Spread's the +/// edge of the spread it detected (`exit::take_is_recorded`). +const NOT_SELL_PRICE: &[&str] = &[super::hook::KIND_MOONHOOK, super::exit::KIND_SPREAD]; const ANY: &[&str] = &[]; const GRID_PRICE: &[f64] = &[ @@ -260,8 +263,9 @@ pub const TICK_PARAMS: &[TickParam] = &[ grid: GRID_SELL_PRICE, }, kinds: ANY, - // A MoonHook carries no `SellPrice` at all — `HookSellLevel` below is its take. - not_kinds: HOOK, + // A MoonHook carries no `SellPrice` at all — `HookSellLevel` below is its take — and a + // Spread's take is the spread it detected, whatever the field says. + not_kinds: NOT_SELL_PRICE, }, TickParam { key: "MShotSellAtLastPrice", @@ -380,6 +384,11 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "UseStopLoss", "FastStopLoss", "StopLossEMA", + // The switches of the sell rules the model does not have: a trade under one is not + // modelled (`exit::UnmodelledRule`). + "UseTrailing", + "UseSecondStop", + "UseStopLoss3", // PumpsDetection's one sell move (see `line::PUMP_MOVE_LAG_MS`); `PumpMovePersent` is the // core's own spelling of the field. "PumpMoveTimer", @@ -564,7 +573,24 @@ pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { // the book-watching stop, never as the fast stop's "StopLoss Market Sell". fast_stop_loss: v.bool("FastStopLoss", false), stop_loss_ema: v.num("StopLossEMA", base.stop_loss_ema), + unmodelled: unmodelled_rule(v), latency_ms: base.latency_ms, take_from_archive: base.take_from_archive, } } + +/// The first sell rule the strategy switched on that the model does not have, if any. +/// +/// Read by the switch, never by its fields: the fields stay in a strategy's dump with the switch +/// off (`assets/param_deps.toml`). On this machine's reports (2026-09-23) the trailing stop was +/// on for the strategies of 44 trades of 2 042 and the stop ladder for 2; `SellSpread` and the EMA exit +/// were on for none, and are left out until a strategy turns them on. +fn unmodelled_rule(v: &StrategyValues<'_>) -> Option { + if v.bool("UseTrailing", false) { + Some(UnmodelledRule::Trailing) + } else if v.bool("UseSecondStop", false) || v.bool("UseStopLoss3", false) { + Some(UnmodelledRule::StopLadder) + } else { + None + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/record.rs b/crates/moon-core/src/db/tuner/ticks/record.rs new file mode 100644 index 00000000..f3066ca7 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/record.rs @@ -0,0 +1,148 @@ +//! The core's own record of a trade — the report row and the order archive — turned into the +//! model's inputs, and the rule for which trades a search may be run on. +//! +//! One place for both callers: the axis' load (`moon-ui-gpui`, `ticks/load.rs`) and the +//! `tests::real_data` bench prepare every deal through [`prepare_deal`], so the bench measures +//! what the table shows. +//! +//! **What the fact proves about the stop** ([`StopAnchor`]). The stop watches the book — the +//! BID, averaged, or the price our size would sell at — and the tape carries no book, so the +//! model fires it by a proxy that reproduces about half of the book stops (2026-09-23: 83 of +//! 173). Its sale is a panic limit or a market order walked through that book, 0.4–6 % past +//! the trigger print. Neither is +//! a guess on the trade itself: the core's stop, under its own settings, fired when the core's +//! record says and sold at the report's price, and did NOT fire before — before its activation +//! when the trade was stopped, before the close when it was not. A variant that keeps the +//! entry and every stop setting inherits exactly that; one that changes either is back on the +//! proxy, and the verdict (which replays the proxy, never the anchor) is what says how far the +//! proxy may be trusted. + +use super::exit::{ExitParams, archived_pre_spike_ask, archived_take, stop_pct}; +use super::verify::{ + POINT_TIME_TOLERANCE_MS, REASON_STOP, Verdict, archived_stop_jump, reason_starts_with, + stated_stop_level, +}; +use super::{Deal, EntryParams, Fill}; + +/// The fact's stop, as a variant running the same one may lean on it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StopAnchor { + /// The entry the fact's stop counted from — the buy's price and moment. + pub entry_price: f64, + pub entry_ms: i64, + /// The stop the fact ran: its adjusted distance ([`stop_pct`]), delay and trigger. + pub stop_pct: f64, + pub delay_s: f64, + pub fast: bool, + pub ema: f64, + /// When the fact's stop fired and the price it sold at; `None` when the fact did not stop. + pub fired: Option<(i64, f64)>, + /// Up to when the fact proves the stop quiet: its activation, or the close. + pub quiet_until_ms: i64, +} + +impl StopAnchor { + /// The anchor of a trade whose fact ran `exit`. + /// + /// The moment a stopped trade's stop fired: the order archive's first move at or past the + /// stop's level (the panic sell's jump), else the close — the sale completes within a second + /// or two of the activation, and on a trade with no record of the moment that is the nearest + /// the fact gets. + /// + /// Args: + /// deal: The trade. + /// exit: The sell parameters of the fact. + /// exit_points: The archived Exit line, when the archive holds it. + pub fn of(deal: &Deal, exit: &ExitParams, exit_points: Option<&[(i64, f64)]>) -> Self { + let pct = stop_pct(exit, deal); + // The verdict's own test of a stopped fact (`verify::reason_starts_with`), not a copy. + let stopped = reason_starts_with(deal.sell_reason.trim(), REASON_STOP); + let fired = stopped.then(|| { + // The level the jump is read against: the core's own when its reason kept it. + let level = stated_stop_level(&deal.sell_reason).unwrap_or(if deal.is_long() { + deal.buy_price * (1.0 + pct / 100.0) + } else { + deal.buy_price * (1.0 - pct / 100.0) + }); + let at = archived_stop_jump(deal, level, exit_points).unwrap_or(deal.close_ms); + (at, deal.sell_price) + }); + Self { + entry_price: deal.buy_price, + entry_ms: deal.buy_ms, + stop_pct: pct, + delay_s: exit.stop_loss_delay_s, + fast: exit.fast_stop_loss, + ema: exit.stop_loss_ema, + fired, + quiet_until_ms: fired.map_or(deal.close_ms, |(t, _)| t), + } + } + + /// Whether a walk from `fill` under `params` runs the fact's own stop — the same entry (to + /// the price, and within the point tolerance in time) and the same stop settings. + /// + /// Args: + /// deal: The trade, for the adjusted stop distance. + /// fill: The walk's entry. + /// params: The walk's sell parameters. + pub fn holds(&self, deal: &Deal, fill: Fill, params: &ExitParams) -> bool { + fill.price == self.entry_price + && (fill.t_ms - self.entry_ms).abs() <= POINT_TIME_TOLERANCE_MS + && stop_pct(params, deal) == self.stop_pct + && params.stop_loss_delay_s == self.delay_s + && params.fast_stop_loss == self.fast + && params.stop_loss_ema == self.ema + } +} + +/// Fill the model inputs the core's own record gives: the ask a MoonShot's take was lifted to, +/// read back off the take as placed, the take itself, the stop anchor, and the entry settings +/// the trade ran with ([`Deal::own_entry`]). +/// +/// Args: +/// deal: The trade, filled in place. +/// entry: The entry parameters as of the buy. +/// exit: The sell parameters as of the buy. +/// exit_points: The archived Exit line, when the archive holds it. +pub fn prepare_deal( + deal: &mut Deal, + entry: &EntryParams, + exit: &ExitParams, + exit_points: Option<&[(i64, f64)]>, +) { + deal.pre_spike_ask = archived_pre_spike_ask(exit_points, exit, deal.is_short); + deal.archived_take = archived_take(exit_points); + deal.stop_anchor = Some(StopAnchor::of(deal, exit, exit_points)); + deal.own_entry = Some(entry.clone()); +} + +/// The deal as the verdict replays it: without what the fact proves ([`Deal::stop_anchor`], +/// [`Deal::own_entry`]) — the verdict exists to test the model, and a model handed the fact +/// passes by construction. +pub fn unanchored(deal: &Deal) -> Deal { + Deal { + stop_anchor: None, + own_entry: None, + ..deal.clone() + } +} + +/// Whether a trade may be searched over: the model reproduced it — the exit judged and right, +/// the entry right or taken from the fact. +/// +/// A trade the model does not reproduce under the strategy's own parameters is one whose +/// behaviour it cannot model — a book it has no copy of, a rule it does not have, an input the +/// record did not keep — and what it answers for a variant of that trade is not an answer. The +/// developer's call (2026-09-23): such trades stay in the table with their verdict, and out of +/// the variants and the search. An exit left unjudged (`None`) is out too: it is a rule the +/// model does not have, not a pass. +/// +/// Args: +/// verdict: The trade's verdict on its own parameters. +pub fn fit_for_search(verdict: &Verdict) -> bool { + verdict.entry != Some(false) && verdict.exit == Some(true) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/record/tests.rs b/crates/moon-core/src/db/tuner/ticks/record/tests.rs new file mode 100644 index 00000000..044bdb1c --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -0,0 +1,242 @@ +//! The core's own record as the model's inputs: the stop anchor, the fallback lines, the rule +//! for the search's sample. + +use super::*; +use crate::db::tuner::ticks::line::walk; +use crate::db::tuner::ticks::mshot::MshotParams; +use crate::db::tuner::ticks::{Deltas, ExitKind, simulate, verify}; +use crate::feed::types::{Side, Tick}; + +fn tick(t_ms: i64, price: f64) -> Tick { + Tick { + time_ms: t_ms as f64, + price: price as f32, + qty: 1.0, + side: Side::Buy, + } +} + +/// A taker sell — the print a long's book stop reads its BID proxy off. +fn sold(t_ms: i64, price: f64) -> Tick { + Tick { + side: Side::Sell, + ..tick(t_ms, price) + } +} + +const BOOK_REASON: &str = "StopLoss AutoActivated on price drop: BID = 98.800 ASK: 98.900 \ + (strategy ); StopLoss fixed: 99.000 AllowedDrop: BUY -15.0%"; + +/// A long bought at 100 at 0, stopped by the book, sold at 97 at 4 050. +fn stopped() -> Deal { + Deal { + report_uid: 1, + core_uid: 7, + core_name: "C".into(), + strategy_id: 42, + kind: "PumpsDetection".into(), + coin: "ACE".into(), + buy_ms: 0, + close_ms: 4_050, + buy_price: 100.0, + sell_price: 97.0, + spent: 1_000.0, + is_short: false, + sell_reason: BOOK_REASON.into(), + fact_pnl: -3.0, + profit: None, + deltas: Deltas::default(), + tick: None, + pre_spike_ask: None, + archived_take: None, + hook_depth_pct: None, + hook_stated_take_pct: None, + step_lag_ms: 0.0, + stop_anchor: None, + own_entry: None, + } +} + +/// A 1 % book stop without latency. +fn book() -> ExitParams { + ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: false, + latency_ms: 0.0, + ..ExitParams::default() + } +} + +fn fact_fill() -> Fill { + Fill { + t_ms: 0, + price: 100.0, + } +} + +/// The archived Exit line of [`stopped`]: the take, then the panic sell's first price past the +/// stop's level at 3 800. +const PANIC_LINE: [(i64, f64); 2] = [(0, 101.0), (3_800, 98.1)]; + +/// The archive names the moment: the anchor fires the core's stop there, at the report's price +/// — not at the sample the proxy would have fired on, nor at the proxy's price. +#[test] +fn the_anchor_fires_the_facts_own_stop_at_its_moment_and_price() { + let mut d = stopped(); + d.stop_anchor = Some(StopAnchor::of(&d, &book(), Some(&PANIC_LINE))); + let ticks = vec![sold(1_500, 99.5), sold(2_500, 98.8), tick(5_000, 100.0)]; + let w = walk(&d, &ticks, fact_fill(), 101.0, &book()); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 3_800)); + assert!((w.exit.price - 97.0).abs() < 1e-9); + // A variant whose line a print reaches before it sells there: the line is the model's. + let w = walk(&d, &ticks, fact_fill(), 99.5, &book()); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Take, 1_500)); + // A variant with another stop is back on the proxy: at 2 % the BID never gets there. + let deeper = ExitParams { + stop_loss_pct: -2.0, + ..book() + }; + let w = walk(&d, &ticks, fact_fill(), 101.0, &deeper); + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd); +} + +/// A trade the stop never fired on proves it quiet to the close: the fast stop's print inside +/// that span is not a stop under the trade's own settings, one after it is. +#[test] +fn a_trade_its_stop_never_fired_on_keeps_it_quiet_until_the_close() { + let mut d = stopped(); + d.sell_reason = "Auto Price Down".into(); + d.close_ms = 6_000; + let fast = ExitParams { + fast_stop_loss: true, + ..book() + }; + let ticks = vec![tick(1_000, 98.5), tick(7_000, 98.5)]; + let w = walk(&d, &ticks, fact_fill(), 101.0, &fast); + assert_eq!( + w.exit.t_ms, 1_000, + "without the anchor the first print fires" + ); + d.stop_anchor = Some(StopAnchor::of(&d, &fast, None)); + let w = walk(&d, &ticks, fact_fill(), 101.0, &fast); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 7_000)); +} + +/// The anchor is the trade's own stop and entry, nothing near them. +#[test] +fn the_anchor_holds_only_for_the_trades_own_entry_and_stop() { + let d = stopped(); + let anchor = StopAnchor::of(&d, &book(), None); + assert!(anchor.holds(&d, fact_fill(), &book())); + let late = Fill { + t_ms: 1_500, + ..fact_fill() + }; + let deeper_entry = Fill { + price: 99.9, + ..fact_fill() + }; + assert!(!anchor.holds(&d, late, &book())); + assert!(!anchor.holds(&d, deeper_entry, &book())); + for other in [ + ExitParams { + stop_loss_delay_s: 5.0, + ..book() + }, + ExitParams { + fast_stop_loss: true, + ..book() + }, + ExitParams { + stop_loss_ema: 3.0, + ..book() + }, + ExitParams { + stop_loss_pct: -1.5, + ..book() + }, + ] { + assert!(!anchor.holds(&d, fact_fill(), &other), "{other:?}"); + } + // The take and the sell line are not the stop's: a variant moving them keeps the anchor. + let other_take = ExitParams { + sell_price_pct: 3.0, + price_down_timer_s: 1.0, + price_down_pct: 50.0, + ..book() + }; + assert!(anchor.holds(&d, fact_fill(), &other_take)); +} + +/// The stop's moment: the archive's jump past the level, else the close. +#[test] +fn the_stop_moment_comes_from_the_archive_then_the_close() { + let d = stopped(); + assert_eq!(StopAnchor::of(&d, &book(), None).fired, Some((4_050, 97.0))); + // The archived line: the take, a step, and the panic sell's first price past 99. + let line = [(0, 101.0), (1_000, 100.8), (3_600, 98.1), (4_040, 97.0)]; + let anchor = StopAnchor::of(&d, &book(), Some(&line)); + assert_eq!(anchor.fired, Some((3_600, 97.0))); + assert_eq!(anchor.quiet_until_ms, 3_600); +} + +/// The verdict tests the proxy, never the anchor: the core's activation 5.5 s after the proxy's +/// sample is a miss of the model, even though a variant would sell exactly where the core did. +#[test] +fn the_verdict_never_leans_on_the_anchor() { + let mut d = stopped(); + d.close_ms = 8_100; + let line = [(0, 101.0), (8_000, 98.1)]; + d.stop_anchor = Some(StopAnchor::of(&d, &book(), Some(&line))); + let ticks = vec![sold(1_500, 99.5), sold(2_500, 98.8), tick(9_000, 100.0)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &book(), None, None); + assert_eq!( + v.exit, + Some(false), + "the proxy fired at 4 s, the core at 8 s: {v:?}" + ); + let w = walk(&d, &ticks, fact_fill(), 101.0, &book()); + assert_eq!((w.exit.t_ms, w.exit.price), (8_000, 97.0)); +} + +/// A variant running the trade's own entry settings filled where the report says — the entry +/// model is for the settings the core never ran. +#[test] +fn a_variant_running_the_trades_own_entry_fills_at_the_fact() { + let mut d = stopped(); + d.kind = "MoonShot".into(); + let own = EntryParams::MoonShot(MshotParams::default()); + prepare_deal(&mut d, &own, &book(), None); + // A tape the corridor never reaches: the model alone would not fill at all. + let ticks = vec![ + tick(-20_000, 100.0), + tick(-10_000, 100.0), + tick(5_000, 100.0), + ]; + let outcome = simulate(&d, &ticks, &own, &book(), None); + assert_eq!(outcome.fill, Some(fact_fill())); + let other = EntryParams::MoonShot(MshotParams { + price_pct: 9.0, + ..MshotParams::default() + }); + assert_eq!(simulate(&d, &ticks, &other, &book(), None).fill, None); +} + +#[test] +fn only_a_reproduced_trade_is_searched() { + let verdict = |entry, exit| Verdict { + entry, + entry_dev_pct: None, + exit, + exit_dev_pct: None, + fill: None, + exit_kind: None, + line_points: None, + }; + assert!(fit_for_search(&verdict(None, Some(true)))); + assert!(fit_for_search(&verdict(Some(true), Some(true)))); + assert!(!fit_for_search(&verdict(Some(false), Some(true)))); + assert!(!fit_for_search(&verdict(Some(true), Some(false)))); + // An exit the model has no rule for is not a pass. + assert!(!fit_for_search(&verdict(Some(true), None))); +} diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index b315a4c7..228e7b1d 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -16,7 +16,7 @@ fn tick(t_ms: i64, price: f64) -> Tick { } } -/// A Spread deal (entry from the fact) bought at 100 whose tape peaks at `peak` after the +/// A PumpsDetection deal (entry from the fact, take by `SellPrice`) bought at 100 whose tape peaks at `peak` after the /// fill, then falls back to the fact's exit. fn prepared(uid: i64, peak: f64) -> PreparedDeal { let deal = Deal { @@ -24,7 +24,7 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { core_uid: 1, core_name: String::new(), strategy_id: 1, - kind: "Spread".into(), + kind: "PumpsDetection".into(), coin: "ACE".into(), buy_ms: 1_000 * uid, close_ms: 1_000 * uid + 900, @@ -42,6 +42,8 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, + stop_anchor: None, + own_entry: None, }; let t0 = deal.buy_ms; let ticks: Vec = vec![ @@ -89,7 +91,7 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { let params = SearchParams { base: &base, defaults: &defaults, - kind: "Spread", + kind: "PumpsDetection", vary_entry: false, vary_exit: true, locked: &locked, @@ -115,7 +117,14 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { assert!(result.holdout.is_none()); assert_eq!(handle.completed(), 3); // The same values through the variant column. - let (tally, spent) = variant_tally(&deals, &base, &defaults, "Spread", &result.values, 0.0); + let (tally, spent) = variant_tally( + &deals, + &base, + &defaults, + "PumpsDetection", + &result.values, + 0.0, + ); assert!((tally.profit - 80.0).abs() < 1e-6); assert!((spent - 8_000.0).abs() < 1e-6); } @@ -135,7 +144,7 @@ fn the_holdout_is_scored_but_never_fitted_on() { let params = SearchParams { base: &base, defaults: &defaults, - kind: "Spread", + kind: "PumpsDetection", vary_entry: false, vary_exit: true, locked: &locked, @@ -162,7 +171,7 @@ fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { let params = SearchParams { base: &base, defaults: &defaults, - kind: "Spread", + kind: "PumpsDetection", vary_entry: true, vary_exit: true, locked: &all, diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index 91c9267f..e64893ad 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -25,6 +25,8 @@ fn deal(pnl: f64, spent: f64) -> Deal { hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, + stop_anchor: None, + own_entry: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 28f6726a..e0882db6 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -52,6 +52,8 @@ fn deal() -> Deal { hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, + stop_anchor: None, + own_entry: None, } } @@ -1126,10 +1128,15 @@ fn the_descriptor_keys_every_field_the_builders_read_and_splits_the_groups() { } assert!(params_for(ParamGroup::Entry, "Spread").next().is_none()); assert!(params_for(ParamGroup::Entry, "MoonShot").count() > 10); - let exit_any: Vec<_> = params_for(ParamGroup::Exit, "Spread") + let exit_any: Vec<_> = params_for(ParamGroup::Exit, "PumpsDetection") .map(|p| p.key) .collect(); assert!(exit_any.starts_with(&["SellPrice", "SellDelay", "PriceDownTimer"])); + // A Spread's take is the spread it detected, not `SellPrice` (`exit::take_is_recorded`). + let spread: Vec<_> = params_for(ParamGroup::Exit, "Spread") + .map(|p| p.key) + .collect(); + assert!(spread.starts_with(&["SellDelay", "PriceDownTimer"])); assert!( !exit_any.contains(&"MShotSellAtLastPrice"), "a MoonShot-only field" @@ -1159,6 +1166,8 @@ fn hook_deal() -> Deal { hook_depth_pct: Some(4.0), hook_stated_take_pct: Some(2.0), step_lag_ms: 0.0, + stop_anchor: None, + own_entry: None, ..deal() } } @@ -1231,22 +1240,70 @@ fn a_hook_without_its_depth_is_not_a_known_take() { ..params.clone() }; assert!(!ExitModel::new(&fixed).take_known(&hook_deal())); - // Every other kind takes by `SellPrice`, which the model has — with or without an archive. + // The kinds that take by `SellPrice` have it — with or without an archive. assert!(model.take_known(&deal()), "MoonShot"); - for kind in ["Spread", "PumpsDetection", "Combo"] { + for kind in ["PumpsDetection", "Combo"] { let d = Deal { kind: kind.into(), ..deal() }; assert!(model.take_known(&d), "{kind} takes by SellPrice"); } - // An archived level answers for a hook the formula cannot reach. - assert!(model.take_known(&Deal { + // A variant runs the hook's FORMULA: the level the core recorded is the fact's answer, and + // a variant of a hook whose depth is unknown has nowhere to put its take. + assert!(!model.take_known(&Deal { archived_take: Some(101.0), ..no_depth })); } +/// The take of a Spread is the spread it detected, a level the core recorded, not a rule: the +/// record or nothing, for the fact and for every variant — and never `SellPrice`. +#[test] +fn a_spread_takes_the_level_its_core_recorded() { + let spread = Deal { + kind: "Spread".into(), + archived_take: Some(102.3), + ..deal() + }; + let far = ExitParams { + sell_price_pct: 5.0, + ..ExitParams::default() + }; + let model = ExitModel::new(&far); + let fill = Fill { + t_ms: 10_000, + price: 100.0, + }; + assert!(model.take_known(&spread)); + assert!((model.take_level(&spread, &[], fill) - 102.3).abs() < 1e-9); + let unrecorded = Deal { + archived_take: None, + ..spread + }; + assert!(!model.take_known(&unrecorded)); +} + +/// A MoonShot lifted to the pre-spike ask places its take off the ask the core's record gives +/// back; a variant with nothing but the tape's print would place it lower, and on a stopped +/// trade sell there before the stop (30 of 88 live, 2026-09-23). +#[test] +fn a_moonshot_lifted_to_the_ask_needs_the_recorded_ask() { + let lifted = ExitParams { + sell_at_last_price: true, + sell_price_adjust_pct: 0.1, + ..ExitParams::default() + }; + let model = ExitModel::new(&lifted); + assert!(!model.take_known(&deal())); + assert!(model.take_known(&Deal { + pre_spike_ask: Some(103.0), + ..deal() + })); + // Without the lift the take is `SellPrice`, known either way. + assert!(ExitModel::new(&ExitParams::default()).take_known(&deal())); +} + /// A modifier deep enough to drive the distance negative must not put the take on the losing /// side of the entry — the line steps DOWN from the take, and a take below the fill inverts it. #[test] @@ -1289,11 +1346,16 @@ fn the_grid_hides_sell_price_from_a_hook_and_offers_its_own_level() { !hook.contains(&"HookSellFixed"), "read, but not modelled — so not a knob" ); + let pump: Vec<&str> = params_for(ParamGroup::Exit, "PumpsDetection") + .map(|p| p.key) + .collect(); + assert!(pump.contains(&"SellPrice")); + assert!(!pump.contains(&"HookSellLevel"), "a hook-only field"); + // Nor is `SellPrice` a Spread's take: the core places it on the spread it detected. let spread: Vec<&str> = params_for(ParamGroup::Exit, "Spread") .map(|p| p.key) .collect(); - assert!(spread.contains(&"SellPrice")); - assert!(!spread.contains(&"HookSellLevel"), "a hook-only field"); + assert!(!spread.contains(&"SellPrice")); } /// The verdict on a take it cannot place is nothing, not a miss — the whole point of the @@ -1325,11 +1387,13 @@ fn an_unknown_take_leaves_the_exit_unanswered() { let v = verify(&blind, &ticks, &EntryParams::Fact, ¶ms, None, None); assert_eq!(v.exit, None, "no level, no verdict"); assert_eq!(v.exit_dev_pct, None); - // A stop is judged all the same: it fires off `StopLoss`, not off the take. + // A stopped trade is no exception: its stop may fire right, but a variant of it walks a line + // off a take it cannot place, and on live trades sold there before the stop (2026-09-23, + // 30 of 88 stopped MoonShot trades) — not a trade the search can run. let stopped = Deal { sell_reason: "StopLoss Market Sell".into(), sell_price: 97.0, - ..blind + ..blind.clone() }; let stop_params = ExitParams { stop_loss_pct: -2.0, @@ -1344,13 +1408,29 @@ fn an_unknown_take_leaves_the_exit_unanswered() { None, None, ); - assert!(v.exit.is_some(), "the stop does not depend on the take"); + assert_eq!( + v.exit, None, + "a stop on an unknown take is still an unknown take" + ); + // With the depth the take is placeable and the stop is judged as a stop. + let v = verify( + &Deal { + hook_depth_pct: hook_deal().hook_depth_pct, + ..stopped + }, + &down, + &EntryParams::Fact, + &stop_params, + None, + None, + ); + assert!(v.exit.is_some(), "{v:?}"); } // ---- the stop and its modifier ------------------------------------------------------------- -/// The core's own log line, verbatim: `StopLoss adjusted [-2.00% - (0.20*1.86=0.37%) => -2.37%]`. -/// 136 such lines were read off this machine's cores and every one obeys this arithmetic. +/// The core's FAQ spells the stop's adjustment as `StopLoss adjusted [-1.00% - (10.00*0.98=9.75%) +/// => -10.75%]`: the configured stop, deepened by `StopLossModifier · Σ`. #[test] fn the_stop_modifier_deepens_the_stop_by_the_summed_deltas() { let mut mods = Modifiers::default(); diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 950115d8..fd70f0b5 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -263,6 +263,12 @@ fn real_data_reproduction() { eprintln!("PriceDown step lag per core: {core_lags:?}"); let (mut entry_hits, mut entry_n, mut exit_hits, mut exit_n, mut with_tape) = (0, 0, 0, 0, 0); let mut kinds_seen: HashMap = HashMap::new(); + let mut unfit: HashMap = HashMap::new(); + // Deals whose core no identity line named: skipped, and counted, so an empty `logs/` folder + // reads as that in the summary rather than as a scope without tape. + let mut no_venue = 0usize; + let (mut fit_n, mut own_n, mut own_close) = (0usize, 0usize, 0usize); + let (mut own_sum, mut fact_sum) = (0.0f64, 0.0f64); for mut deal in read.deals { *kinds_seen.entry(deal.kind.clone()).or_default() += 1; // Every kind the axis takes, as the table does: a kind without an entry model replays @@ -294,6 +300,7 @@ fn real_data_reproduction() { // tape, and the axis reads the deal's own (`RowAddress::exchange_key`). A core the logs // never named is skipped rather than replayed on a mixture. let Some(venue) = venue_of_core.get(&deal.core_uid) else { + no_venue += 1; continue; }; for (exchange, market) in pairs @@ -325,8 +332,7 @@ fn real_data_reproduction() { }; let exit = exit_params(&sv); deal.step_lag_ms = core_lags.get(&deal.core_uid).copied().unwrap_or(0.0); - deal.pre_spike_ask = archived_pre_spike_ask(exit_points.as_deref(), &exit, deal.is_short); - deal.archived_take = archived_take(exit_points.as_deref()); + prepare_deal(&mut deal, &entry, &exit, exit_points.as_deref()); // The modelled line beside the archive's moves, for the eye. if let (Some(fill), Some(moves)) = ( simulate(&deal, &ticks, &entry, &exit, entry_line.as_deref()).fill, @@ -353,8 +359,12 @@ fn real_data_reproduction() { ..exit.clone() }; let fact_fill = verify::fact_sell_start(&deal, &exit, exit_points.as_deref()); - let held = - ExitModel::new(&fact_exit).walk_held(&deal, &ticks, fact_fill, deal.close_ms); + let held = ExitModel::new(&fact_exit).walk_held( + &super::super::record::unanchored(&deal), + &ticks, + fact_fill, + deal.close_ms, + ); if let Ok(dir) = std::env::var("MOON_TICKS_DUMP") { dump_deal( &dir, @@ -430,6 +440,41 @@ fn real_data_reproduction() { .map(|d| super::super::hook::hook_take_pct(d, exit.hook_sell_level_pct)) ), ); + // Which trades the search may run on, and — for those — how the trade's own settings + // replay against the fact, with everything the fact proves in hand: the check that a + // variant column counts the same money the "Fact" column does. + let fit = fit_for_search(&archived); + let own = simulate(&deal, &ticks, &entry, &exit, entry_line.as_deref()); + let fact = profit_pct(&deal, deal.buy_price, deal.sell_price); + eprintln!( + " fit {fit} · own {:?} {:?} at {:?} · fact {:?}", + own.exit.map(|e| e.kind), + round3(own.profit_pct), + own.exit.map(|e| e.t_ms - deal.close_ms), + round3(fact), + ); + if fit { + fit_n += 1; + if let (Some(own), Some(fact)) = (own.profit_pct, fact) { + own_sum += own; + fact_sum += fact; + own_close += usize::from((own - fact).abs() <= 0.05); + own_n += 1; + } + } else { + let why = if archived.entry == Some(false) { + "entry ✗".to_string() + } else if exit.unmodelled.is_some() { + format!("rule not modelled ({:?})", exit.unmodelled) + } else if archived.exit.is_none() { + "exit not judged".to_string() + } else { + let reason = deal.sell_reason.trim(); + let reason = reason.get(..reason.len().min(22)).unwrap_or(reason); + format!("exit ✗ {reason}") + }; + *unfit.entry(why).or_default() += 1; + } // Counted as the axis counts it (`load.rs::replay_row_with` passes both archived lines // whenever it has them): the entry off its archived line when there is one — without it // the two verdicts are the same call — and the exit ALWAYS against the archived Exit @@ -451,6 +496,15 @@ fn real_data_reproduction() { } eprintln!("kinds: {kinds_seen:?}"); eprintln!( - "with tape: {with_tape} · entry ✓ {entry_hits}/{entry_n} · exit ✓ {exit_hits}/{exit_n}" + "with tape: {with_tape} · entry ✓ {entry_hits}/{entry_n} · exit ✓ {exit_hits}/{exit_n} · \ + skipped, core venue unknown: {no_venue}" + ); + let mut unfit: Vec<(String, usize)> = unfit.into_iter().collect(); + unfit.sort_by_key(|u| std::cmp::Reverse(u.1)); + eprintln!("fit for the search: {fit_n} of {with_tape} · left out: {unfit:?}"); + eprintln!( + "own settings replayed on the fit trades: {own_n} closed, mean {:.3} % against the fact's {:.3} %, within 0.05 pp on {own_close}", + own_sum / own_n.max(1) as f64, + fact_sum / own_n.max(1) as f64, ); } diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 84d23932..0286ae7f 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -10,13 +10,15 @@ //! it is not modelled; an exit the core closed by a rule the model does not have is not a miss //! of the rules it does. An exit the model never reached at all IS a miss. //! -//! The same holds for the LEVEL the exit is judged against. `SellPrice` is the take of every -//! kind the core has but one, so this bites on MoonHook alone: with no archived line to read the -//! level off, and no detect depth or `HookSellLevel` to compute it from (or with `HookSellFixed`, -//! whose branch is not modelled), the sell line stands somewhere the model invented, and -//! everything downstream of it — where PriceDown stepped to, whether a level stood at the close — -//! is invented with it. That answers `None`, whatever the deviation says -//! ([`ExitModel::take_known`]). A stop is exempt: it fires off `StopLoss`, not off the take. +//! The same holds for the LEVEL the exit is judged against, as a VARIANT would place it: a hook +//! without its detect depth or `HookSellLevel` (or with `HookSellFixed`, whose branch is not +//! modelled), a Spread without the take the core recorded, a MoonShot lifted to a pre-spike ask +//! the record did not keep — the sell line a variant walks stands somewhere the model invented, +//! and everything downstream of it — where PriceDown stepped to, whether a level stood at the +//! close — is invented with it. That answers `None`, whatever the deviation says +//! ([`ExitModel::take_known`]), and a stopped trade is no exception: its stop may be judged +//! right, but a variant of it sells on the invented take first (2026-09-23: 30 of 88 stopped +//! MoonShot trades). //! //! The entry is held to the CORRIDOR, not to a price step: a MoonShot order chasing a falling //! price is re-placed off whichever print left the corridor, and the core's print and the @@ -36,11 +38,9 @@ //! sold the core's line on ARX and left it standing on COOL the same day. The archive's own //! record of the fill — its last point, at the sale price — is not a move ([`is_fill_point`]). //! -//! A stop is judged by its firing, not by a resting line: the fast stop by its price, a -//! market order on the print; the book-watching stop, whose sale is a panic sell walked -//! through a book the tape does not carry, by the level the core printed into its reason and -//! the moment it activated ([`verify_stop`]). A stop the core fired and the model never did is -//! a miss. +//! A stop is judged by its firing, not by a resting line, and not by its sale either — a panic +//! sell or a market order walked through a book the tape does not carry: by the level the core +//! fixed, when the stored reason keeps it, and the moment it activated ([`verify_stop`]). A stop the core fired and the model never did is a miss. use super::exit::{ExitModel, stop_pct}; use super::line::LinePoint; @@ -54,9 +54,9 @@ use crate::feed::types::Tick; /// move: the archive stamps the core's own moment, the model the print that triggered it. pub const POINT_TIME_TOLERANCE_MS: i64 = 1_000; -/// Tolerance on a STOP's price: the core's stop is a market order, and the report's -/// `sellprice` is what the book gave for it, while the model knows only the print that -/// fired it. Measured on the live tape (2026-09-20): 0.16–0.28 % between the two on a spike. +/// Tolerance on a STOP's level: the modelled level against the one the core fixed carries +/// `StopLossModifier` over the report's ONE snapshot of the deltas, which the core re-reads live +/// (`exit::modifier_sum`), and the residual sits right there. pub const STOP_PRICE_TOLERANCE: f64 = 0.003; /// How much BETTER than the modelled level the fact's fill may be and still be that level's @@ -123,6 +123,9 @@ pub fn verify( entry_line: Option<&[(i64, f64)]>, exit_points: Option<&[(i64, f64)]>, ) -> Verdict { + // The model is what is tested: what the fact proves (the stop's firing, the entry's own + // fill) is the variants' to lean on, never the verdict's. + let deal = &super::record::unanchored(deal); let outcome = simulate(deal, ticks, entry, exit, entry_line); let (entry_ok, entry_dev) = match (entry, outcome.fill) { (EntryParams::Fact, _) => (None, None), @@ -208,17 +211,23 @@ pub fn verify( } } }; - // Where the take itself is not modelled for this trade, the line under it is not the - // model's answer but its guess — see the module doc. - let take_known = ExitModel::new(&fact_exit).take_known(deal); + // Where a variant could not place this trade's take, the line under it is not the model's + // answer but its guess — see the module doc. Asked with the trade's OWN parameters as a + // variant runs them (`exit`, not `fact_exit`): the fact's replay starts at the recorded + // take, a variant does not. + let take_known = ExitModel::new(exit).take_known(deal); // A stop the core fired and the model, holding a stop of its own, never did — the book // proxy of a non-fast stop can stay short of the level to the tape's end — is a miss of // the stop, whatever the line was doing: not a question about another rule. let missed_stop = fact_stopped && closed.kind != ExitKind::Stop && stop_pct(&fact_exit, deal) != 0.0; - let (exit_ok, exit_dev, line_points) = if missed_stop { + let (exit_ok, exit_dev, line_points) = if exit.unmodelled.is_some() { + // A rule the model does not have was on: whatever the walk made of the trade is not + // an answer about it (see `ExitParams::unmodelled`). + (None, None, None) + } else if missed_stop { (Some(false), None, None) - } else if !take_known && closed.kind != ExitKind::Stop { + } else if !take_known { (None, None, None) } else if closed.kind == ExitKind::OpenAtWindowEnd { // No line stood at the close: a miss of the exit group, not an unanswered question. @@ -430,19 +439,19 @@ fn is_fill_point(deal: &Deal, exit: &ExitParams, last: (i64, f64), prev: (i64, f at_close || fill_side } -/// The stop's verdict. Two stops, told apart by what the core wrote: +/// The stop's verdict: by what it DECIDED, never by what its sale fetched. +/// +/// Every stop's sale is walked through a book the tape does not carry. Without `UseMarketOrder` +/// the core runs a panic sell — a limit through the book stepped by `StopLossSpread` down to +/// `AllowedDrop` (FAQ) — whose fills sit 1–3 % past the level (0 of 173 passed on the sale +/// price, 2026-09-22); with it (`StopLoss Market Sell`, 149 of 149 such trades) a market order +/// sweeps our size into the bids. /// -/// - **With its level in the reason** — `StopLoss AutoActivated on price drop: BID = … StopLoss -/// fixed: X` — the book-watching stop (`FastStopLoss` off). The core then runs a panic sell: -/// a limit through the book stepped by `StopLossSpread` down to `AllowedDrop` (FAQ), which is -/// where the sale price comes from, and the tape has no book. So the rule is judged by what -/// it decided — the modelled stop level against the core's own `X`, and the moment it fired -/// against the activation — never by the fill. Live sample (2026-09-22): 0 of 173 such stops -/// passed on the sale price, the fills sitting 1–3 % past the level while the core's `X` -/// agreed with the model's level within 0.3 % on 144 of 183. When the stored reason cut the -/// level off, the moment and the line are what is left to judge. -/// - **Without it** — `StopLoss Market Sell`, the fast stop — a market order on the print, -/// judged by its price against the sale as before. +/// So the rule is judged by the modelled stop LEVEL against the one the core fixed — the stored +/// reason's `StopLoss fixed: X`, which a panic sell carries and which is cut off one time in +/// seven — and by the moment it fired against the activation — the archive's jump past the +/// level, else the close. The core's `X` agreed with the model's level within 0.3 % on 144 of +/// 183 (2026-09-22). With no level on record, the moment and the line are what is left to judge. /// /// The level tolerance is [`STOP_PRICE_TOLERANCE`] rather than the line's: the modelled level /// carries `StopLossModifier` over the report's ONE snapshot of the deltas, which the core @@ -475,10 +484,7 @@ fn verify_stop( let mut activation: Option = None; let points = exit_points.filter(|p| !p.is_empty()).map(|archived| { let mut moves = archived_replacements(archived); - if let Some(i) = moves - .iter() - .position(|&(t, p)| t >= deal.buy_ms && reaches(p, panic_at, deal.is_long())) - { + if let Some(i) = stop_jump(deal, &moves, panic_at) { activation = Some(moves[i].0); moves.truncate(i); } @@ -493,23 +499,38 @@ fn verify_stop( let level_ok = dev.is_some_and(|d| d.abs() <= STOP_PRICE_TOLERANCE * 100.0); (Some(level_ok && on_time && line_ok), dev, points) } - // A book-watching stop whose level the stored reason cut off (28 of 206 live): its sale - // is still the panic sell, which never passes on price (0 of 173), so what is left to - // judge is the moment and the line — not a sale price that would fail every one. - None if is_book_stop_reason(&deal.sell_reason) => (Some(on_time && line_ok), None, points), - None => { - let dev = deviation_pct(closed.price, deal.sell_price); - let price_ok = dev.is_some_and(|d| d.abs() <= STOP_PRICE_TOLERANCE * 100.0); - (Some(price_ok && line_ok), dev, points) - } + // No level on record — a book-watching stop whose stored reason cut it off (28 of 206 + // live), or a market stop, whose reason never carries one: the sale is a panic sell or a + // market order swept through a book the tape does not carry, so what is left to judge + // is the moment and the line, never the price. On the live sample (2026-09-23) 18 market + // stops fired on time and failed on the sweep alone; a variant keeping the stop sells at + // the fact's own price (`record::StopAnchor`). + None => (Some(on_time && line_ok), None, points), } } -/// Whether a stop's `sellreason` is the book-watching stop's — `StopLoss AutoActivated on price -/// drop: BID = …` — rather than the fast stop's `StopLoss Market Sell`. The prefix survives the -/// column's truncation, which cuts the text's end. -fn is_book_stop_reason(reason: &str) -> bool { - reason.trim().starts_with("StopLoss AutoActivated") +/// Where the stop took over an archived line: the first move at or after the buy that stands at +/// or past the stop level — the panic sell's first price, or the market order's. +fn stop_jump(deal: &Deal, moves: &[(i64, f64)], level: f64) -> Option { + moves + .iter() + .position(|&(t, p)| t >= deal.buy_ms && reaches(p, level, deal.is_long())) +} + +/// The moment the core's own Exit line jumped past the stop level — the stop's activation as the +/// line records it — or `None` when the line holds no such move. +/// +/// Args: +/// deal: The trade. +/// level: The stop level: the one the core printed when it did, else the model's. +/// exit_points: The core's own Exit line. +pub(super) fn archived_stop_jump( + deal: &Deal, + level: f64, + exit_points: Option<&[(i64, f64)]>, +) -> Option { + let moves = archived_replacements(exit_points?); + stop_jump(deal, &moves, level).map(|i| moves[i].0) } /// The stop level the core printed into a book-watching stop's reason — `StopLoss fixed: X` — @@ -604,7 +625,7 @@ fn exit_rule_matches(kind: ExitKind, sell_reason: &str) -> bool { /// Whether a `sellreason` starts with an ASCII prefix, case-insensitively — on characters, /// never bytes: the reason is database text, and a slice at a byte inside a multi-byte /// character would panic. -fn reason_starts_with(reason: &str, prefix: &str) -> bool { +pub(super) fn reason_starts_with(reason: &str, prefix: &str) -> bool { reason .get(..prefix.len()) .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 0d26fb1d..29fe00e3 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -27,7 +27,8 @@ use crate::analytics::refresh::{CatchUpOutcome, report_result_is_stale}; use moon_core::db::ReadFail; use moon_core::db::order_traces::{TraceEntry, read_many}; use moon_core::db::tuner::ticks::{ - Deal, DealsRead, EntryParams, entry_model_for, infer_tick, params, required_spans, verify, + Deal, DealsRead, EntryParams, entry_model_for, infer_tick, params, prepare_deal, + required_spans, verify, }; use moon_core::db::tuner::{VarStats, Variant, strategy_current_values, strategy_values_at}; use moon_core::feed::report_traces::ArchivedLineKind; @@ -560,6 +561,9 @@ pub(super) fn replay_row( /// Run the model on one row from a tape already asked for. A covered row keeps its tape and /// its archived entry start for the variants; a row without an address is left as it is. +/// +/// The model inputs read off the order archive go through `prepare_deal`, the same call the +/// `real_data` bench makes, so what it measures is what this table shows. pub(super) fn replay_row_with( row: &mut DealRow, defaults: &HashMap, @@ -614,14 +618,9 @@ pub(super) fn replay_row_with( EntryParams::Fact }; let exit = params::exit_params(&sv); - // The ask the core lifted its take to, off the archive — the tape has no book. - row.deal.pre_spike_ask = moon_core::db::tuner::ticks::archived_pre_spike_ask( - lines.exit_points.as_deref(), - &exit, - row.deal.is_short, - ); - row.deal.archived_take = - moon_core::db::tuner::ticks::archived_take(lines.exit_points.as_deref()); + // What the core's own record fixes: the ask its take was lifted to, the take as placed, + // what the fact proves about the stop, the entry the trade ran with. + prepare_deal(&mut row.deal, &entry, &exit, lines.exit_points.as_deref()); // The core's own clock for its PriceDown steps, as the last load calibrated it. row.deal.step_lag_ms = super::lags::step_lag_of(row.deal.core_uid); row.verdict = Some(verify( diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index c7c257cd..a13d42c7 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -4,12 +4,12 @@ //! Left, under the strategy list: the deal table — one row per closed trade with millisecond //! stamps, what came of it, whether the terminal holds its tape, and whether the model //! reproduces the fact; a double-click opens the trade window on it. Every row of the scope is -//! shown; the switch under the table narrows it to the rows whose tape covers the window — the -//! sample the variants and the search run on — and the status line says how many that is out of -//! the scope. The model's verdict is a COLUMN, never a filter. Right: the shared "Fact vs …" -//! matrix (the whole scope, the -//! replayable subset captioned with the ✓ shares, the variant columns), and the parameter grid -//! with the strategies' values, the two variant columns and the search row. +//! shown; the switch under the table narrows it to the sample the variants and the search run +//! on — the rows whose tape covers the window AND which the model reproduces (`DealRow::fit`) — +//! and the status line says how many that is out of the scope. Right: the shared "Fact vs …" +//! matrix (the whole scope, the fit subset captioned with the ✓ shares, the variant columns), +//! and the parameter grid with the strategies' values, the two variant columns and the search +//! row. //! //! The model itself is `moon_core::db::tuner::ticks`; this module only feeds it and draws //! what it says. @@ -56,9 +56,11 @@ impl AnalyticsView { let scope = self.scope_label(); // The order is settled before the data is viewed: both live in `ticks`, and the sort // cache needs the mutable half. It is the SHOWN rows: with the switch on, only the - // ones whose tape covers the window. + // sample the variants run on. let drawn = rows::order_for(&mut self.ticks).len(); - let only_with_tape = self.ticks.only_with_tape; + let only_fit = self.ticks.only_fit; + // The sample the variants and the search run on, for the status line. + let fit = self.ticks.data.data().map(|d| d.fit()).unwrap_or(0); // The rows the scope holds but the table does not show, for the caption and for the // empty state: a period entirely before the millisecond stamps is not an empty period, // and the table must say which it is rather than draw the shared "no trades". @@ -110,7 +112,7 @@ impl AnalyticsView { if self.ticks.tape_reading { t!("analytics.ticks.fetch_reading").to_string() } else { - t!("analytics.ticks.none_with_tape", hidden = total).to_string() + t!("analytics.ticks.none_fit", hidden = total).to_string() }, 10.0, p, @@ -194,22 +196,22 @@ impl AnalyticsView { // out of how many — with what the scope holds beyond the table, and the switch that // hides the rest. How well the MODEL does on that sample is the KPI caption's ✓ // shares (`ticks_kpi`), not a size. - let status = coverage_caption(covered, total, without_ms, left_out.1, left_out.2); - let only_tip = t!("analytics.ticks.only_with_tape_tip").to_string(); + let status = coverage_caption(covered, fit, total, without_ms, left_out.1, left_out.2); + let only_tip = t!("analytics.ticks.only_fit_tip").to_string(); let only_switch = div() .id("an-ticks-only-tape-box") .flex_none() .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(only_tip.clone())).into()) .child( MoonCheckbox::new("an-ticks-only-tape") - .label(t!("analytics.ticks.only_with_tape").to_string()) - .checked(only_with_tape) + .label(t!("analytics.ticks.only_fit").to_string()) + .checked(only_fit) .on_change({ let view = cx.entity(); move |on: &bool, _w, app| { let on = *on; view.update(app, |this, cx| { - this.ticks.only_with_tape = on; + this.ticks.only_fit = on; // The cached order is a permutation of the SHOWN rows. this.ticks.order = None; cx.notify(); @@ -434,18 +436,19 @@ impl AnalyticsView { .into_any_element() } - /// "Fact vs …": the whole scope, the rows the tape covers (captioned with the ✓ shares of - /// both groups — the model's own account of itself), then the variant columns, each over - /// the replayable rows and captioned with how many. + /// "Fact vs …": the whole scope, the rows fit for the search (captioned with how many of the + /// covered ones that is, and the ✓ shares of both groups — the model's own account of + /// itself), then the variant columns, each over the replayable rows and captioned with how + /// many. fn ticks_kpi(&self, p: MoonPalette, cx: &Context) -> AnyElement { - let (covered, total, entry, exit, replayable, horizon) = self + let (fit, covered, entry, exit, replayable, horizon) = self .ticks .data .data() .map(|d| { ( + d.fit(), d.covered(), - d.rows.len(), d.entry_share, d.exit_share, d.replayable().count(), @@ -464,8 +467,8 @@ impl AnalyticsView { // replayable rows hold past their close (`prepared_deals`). let mut subset_sub = t!( "analytics.ticks.subset_sub", - n = covered, - m = total, + n = fit, + m = covered, entry = share(entry), exit = share(exit) ) @@ -759,11 +762,13 @@ fn coin_cell(scale: f32) -> Div { div().flex_1().min_w(px(DEAL_COIN_MIN_W * scale)).truncate() } -/// The status line's coverage part: how many rows have their tape, out of how many, and what -/// the scope holds beyond the table — rows without millisecond stamps always, the service rows -/// and the untunable ones only when there are any. +/// The status line's coverage part: how many rows have their tape, out of how many, how many of +/// those the model reproduces — the sample the variants and the search run on — and what the +/// scope holds beyond the table: rows without millisecond stamps always, the service rows and the +/// untunable ones only when there are any. fn coverage_caption( covered: usize, + fit: usize, total: usize, without_ms: usize, service: usize, @@ -773,6 +778,7 @@ fn coverage_caption( "analytics.ticks.coverage", covered = covered, total = total, + fit = fit, without = without_ms ) .to_string(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs index e90fcb97..3e58f8ca 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs @@ -1,5 +1,5 @@ -//! The deal table's row order — a permutation over the loaded rows, filtered by the "with tape -//! only" switch and cached against the data generation, the sort and the switch, so a repaint +//! The deal table's row order — a permutation over the loaded rows, filtered by the "fit only" +//! switch and cached against the data generation, the sort and the switch, so a repaint //! that changed none of them reuses it instead of sorting hundreds of deals per frame. use super::columns::*; @@ -9,23 +9,20 @@ use super::state::{DealRow, TapeStatus, TicksState}; pub(in crate::analytics::tuner) struct OrderCache { pub(in crate::analytics::tuner) rows_rev: u64, pub(in crate::analytics::tuner) sort: Option<(String, bool)>, - pub(in crate::analytics::tuner) only_with_tape: bool, + pub(in crate::analytics::tuner) only_fit: bool, /// Indices into `TicksData::rows` — the shown rows only, when the switch hides the rest. pub(in crate::analytics::tuner) order: Vec, } -/// The current order, rebuilt only when the rows, the sort or the "with tape only" switch -/// changed. +/// The current order, rebuilt only when the rows, the sort or the "fit only" switch changed. pub(in crate::analytics::tuner) fn order_for(state: &mut TicksState) -> &[usize] { let fresh = state.order.as_ref().is_some_and(|c| { - c.rows_rev == state.rows_rev - && c.sort == state.sort - && c.only_with_tape == state.only_with_tape + c.rows_rev == state.rows_rev && c.sort == state.sort && c.only_fit == state.only_fit }); if !fresh { let rows: &[DealRow] = state.data.data().map(|d| d.rows.as_slice()).unwrap_or(&[]); let mut order: Vec = (0..rows.len()) - .filter(|&i| !state.only_with_tape || rows[i].tape == TapeStatus::Covered) + .filter(|&i| !state.only_fit || rows[i].fit()) .collect(); if let Some((key, desc)) = &state.sort { sort_indices(rows, &mut order, key, *desc); @@ -33,7 +30,7 @@ pub(in crate::analytics::tuner) fn order_for(state: &mut TicksState) -> &[usize] state.order = Some(OrderCache { rows_rev: state.rows_rev, sort: state.sort.clone(), - only_with_tape: state.only_with_tape, + only_fit: state.only_fit, order, }); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 2cca7846..22f56137 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -28,6 +28,8 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, + stop_anchor: None, + own_entry: None, } } @@ -82,8 +84,8 @@ fn state() -> TicksState { rows[0].held = Some((60_000, 30_000)); rows[1].held = Some((60_000, 60_000)); let mut state = TicksState::default(); - // The sorts are exercised over every row; the "with tape only" switch has its own test. - state.only_with_tape = false; + // The sorts are exercised over every row; the "fit only" switch has its own test. + state.only_fit = false; state.data.apply(Ok(TicksData { rows, ..TicksData::default() @@ -109,27 +111,37 @@ fn the_default_order_is_newest_entry_first() { } #[test] -fn the_tape_switch_keeps_the_covered_rows_whatever_the_model_said() { +fn the_sample_switch_keeps_the_covered_rows_the_model_reproduced() { // Row 2: covered, both groups ✓. Row 1: no tape. Row 3: refused, and the entry a miss. let mut state = state(); assert!( - !TicksState::default().only_with_tape, + !TicksState::default().only_fit, "off by default: the rows without tape are what the fetch button is for" ); - state.only_with_tape = true; + state.only_fit = true; assert_eq!(uids(&mut state), [2]); - // The verdict is the "model" column, never a filter: a covered row the model missed - // stays in the table, and a sample narrowed to what the model already fits would be - // fitted on itself. - state.data.data_mut().unwrap().rows[1].verdict = Some(verdict(Some(false), Some(false))); + // A kind without an entry model answers the entry with nothing: the exit's ✓ is enough. + state.data.data_mut().unwrap().rows[1].verdict = Some(verdict(None, Some(true))); state.rows_rev += 1; assert_eq!(uids(&mut state), [2]); - // A covered row with no verdict at all is in the sample too — the search replays it. + // A covered row the model does not reproduce is out of the sample (the developer's call, + // 2026-09-23): what the model answers for a variant of it is not an answer. The table + // still shows it with the switch off, with its verdict in the "model" column. + for missed in [ + verdict(Some(false), Some(true)), + verdict(Some(true), Some(false)), + verdict(Some(true), None), + ] { + state.data.data_mut().unwrap().rows[1].verdict = Some(missed); + state.rows_rev += 1; + assert!(uids(&mut state).is_empty(), "{missed:?}"); + } + // A covered row the model has not run on yet is not in the sample either. state.data.data_mut().unwrap().rows[1].verdict = None; state.rows_rev += 1; - assert_eq!(uids(&mut state), [2]); + assert!(uids(&mut state).is_empty()); // Flipping the switch alone rebuilds the order: the cache keys on it. - state.only_with_tape = false; + state.only_fit = false; assert_eq!(uids(&mut state), [1, 3, 2]); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index ae5719eb..c81d0cbe 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -18,7 +18,7 @@ use moon_core::db::tuner::VarStats; use moon_core::db::tuner::threshold_search::SearchHandle; use moon_core::db::tuner::ticks::params::ParamGroup; use moon_core::db::tuner::ticks::search::SearchResult; -use moon_core::db::tuner::ticks::{Deal, Verdict}; +use moon_core::db::tuner::ticks::{Deal, Verdict, fit_for_search}; use moon_core::feed::types::Tick; use moon_core::market::trade_replay::TickStatus; @@ -73,9 +73,16 @@ pub(in crate::analytics::tuner) struct DealRow { } impl DealRow { + /// Whether the variants and the search run on this row: its tape covers the window and the + /// model reproduced it (`fit_for_search`). The table shows every row; this is the sample. + pub(in crate::analytics::tuner) fn fit(&self) -> bool { + self.tape == TapeStatus::Covered && self.verdict.as_ref().is_some_and(fit_for_search) + } + /// Take everything a replay learned about this row from its answer: the tape's word, the /// verdict, the model inputs derived for the deal (the price step, the archived pre-spike - /// ask and take, the core's step lag), the prints, the entry line and the held coverage. + /// ask and take, the core's step lag, what the fact proves about the stop, the entry the + /// trade ran with), the prints, the entry line and the held coverage. /// Every fold of a replay answer goes through here: the variants replay the STORED row /// (`prepared_deals`), so a take lifted to the archive's pre-spike ask in the verdict but /// read off the tape in the variants puts the two on different levels, and a row folded @@ -88,6 +95,8 @@ impl DealRow { self.deal.pre_spike_ask = answer.deal.pre_spike_ask; self.deal.archived_take = answer.deal.archived_take; self.deal.step_lag_ms = answer.deal.step_lag_ms; + self.deal.stop_anchor = answer.deal.stop_anchor; + self.deal.own_entry = answer.deal.own_entry; self.ticks = answer.ticks; self.entry_line = answer.entry_line; self.held = answer.held; @@ -130,7 +139,7 @@ pub(in crate::analytics::tuner) struct TicksData { /// the Fact column, not in the table. pub(in crate::analytics::tuner) untunable: usize, /// Column 0: the whole scope (the same SQL as every axis' "Fact", stamps or not); column - /// 1: the rows the tape covers. + /// 1: the rows fit for the search ([`DealRow::fit`]) — the sample the variants replay. pub(in crate::analytics::tuner) kpi: Vec, /// `(hits, answered)` of the entry group over the covered rows. pub(in crate::analytics::tuner) entry_share: (usize, usize), @@ -168,11 +177,16 @@ impl TicksData { .filter(|r| r.tape == TapeStatus::Missing && r.address.is_some()) } - /// Covered rows whose tape is in memory — what the variants and the search replay. + /// Rows fit for the search ([`DealRow::fit`]), tape in memory or not. + pub(in crate::analytics::tuner) fn fit(&self) -> usize { + self.rows.iter().filter(|r| r.fit()).count() + } + + /// Fit rows whose tape is in memory — what the variants and the search replay. A row the + /// model does not reproduce is out whatever its tape: what the model answers for a variant + /// of it is not an answer (`fit_for_search`). pub(in crate::analytics::tuner) fn replayable(&self) -> impl Iterator { - self.rows - .iter() - .filter(|r| r.tape == TapeStatus::Covered && r.ticks.is_some()) + self.rows.iter().filter(|r| r.fit() && r.ticks.is_some()) } /// The share gate per group: whether the model reproduces enough of the fact to be @@ -273,13 +287,18 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) dirty: bool, /// `(column key, descending)` of the deal table. pub(in crate::analytics::tuner) sort: Option<(String, bool)>, - /// The table shows only the rows whose tape covers the window — the sample the variants - /// and the search actually run on ([`TicksData::replayable`]). Off by default: the rows - /// without their tape are the ones the fetch button exists for, and a filter that hides - /// them hides the work to be done. Whether the MODEL reproduces a row is not a filter — - /// it is the "model" column and the search gate (`SHARE_GATE`); a sample narrowed to what - /// the model already fits would be fitted on itself. - pub(in crate::analytics::tuner) only_with_tape: bool, + /// The table shows only the rows fit for the search ([`DealRow::fit`]) — the sample the + /// variants and the search actually run on: tape covering the window, and the model + /// reproducing the trade. Off by default: the rows without their tape are the ones the + /// fetch button exists for, and a filter that hides them hides the work to be done. + /// + /// The model's verdict became part of the sample on the developer's call (2026-09-23): a + /// trade the model cannot reproduce on its own settings — a book it has no copy of, a rule + /// it does not have, an input the record did not keep — answers nothing true for a variant. + /// The worry that held this back before — a sample narrowed to what the model already fits + /// is fitted on itself — is why the cut is the verdict on the trade's OWN settings, taken + /// once per load, never the variant's. + pub(in crate::analytics::tuner) only_fit: bool, /// The sorted row order, cached against `rows_rev` and the sort. pub(in crate::analytics::tuner) order: Option, /// Bumped whenever `data` changes, so the cached order is rebuilt. @@ -322,7 +341,7 @@ impl Default for TicksState { seq: 0, dirty: true, sort: Some((super::columns::COL_TIME.to_string(), true)), - only_with_tape: false, + only_fit: false, order: None, rows_rev: 0, entry_open: true, @@ -525,15 +544,13 @@ impl TicksData { } } - /// Recompute the covered-subset KPI (column 1) and the ✓ shares from the rows — after a - /// fetch changed one of them. Column 0, the whole scope, comes from the same SQL every - /// axis' "Fact" comes from and is left as loaded. + /// Recompute the fit-subset KPI (column 1) and the ✓ shares from the rows — after a fetch + /// changed one of them. Column 0, the whole scope, comes from the same SQL every axis' + /// "Fact" comes from and is left as loaded. The shares stay over every covered row: they are + /// how much of the tape the model reproduces, which is what the fit subset is cut from. pub(in crate::analytics::tuner) fn refresh_summary(&mut self) { let subset = moon_core::db::tuner::ticks::fact_stats( - self.rows - .iter() - .filter(|r| r.tape == TapeStatus::Covered) - .map(|r| &r.deal), + self.rows.iter().filter(|r| r.fit()).map(|r| &r.deal), ); match self.kpi.get_mut(1) { Some(slot) => *slot = subset, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 067c6fc3..1f1c5ab5 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -2,9 +2,13 @@ //! debounced rescore over the replayable rows, the search that fills В1, and the write of В1 //! through the shared confirmation dialog. //! -//! Every score here is a replay — `variant_tally` over the rows whose tape is in memory — so -//! the columns describe the SAME subset the "Fact · with tape" column describes, never the -//! whole scope. The captions say "by N" for that reason. +//! Every score here is a replay — `variant_tally` over the fit rows whose tape is in memory +//! (`TicksData::replayable`) — so the columns describe the SAME subset the "Fact · fit" column +//! describes, never the whole scope. The captions say "by N" for that reason. +//! +//! A replay leans on what each trade's own record proves wherever a variant keeps the trade's +//! own settings: the entry fills where the report says, and the stop fires when and where the +//! core's did (`record::StopAnchor`) — the book the tape does not carry, answered by the fact. use std::collections::{HashMap, HashSet}; use std::time::Duration; diff --git a/locales/analytics.yml b/locales/analytics.yml index 789246e5..199e0fb5 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1709,9 +1709,9 @@ analytics.ticks.title: en: "Trades" es: "Operaciones" analytics.ticks.coverage: - ru: "с лентой %{covered} из %{total} · без мс-штампа %{without}" - en: "tape for %{covered} of %{total} · no ms stamp %{without}" - es: "cinta en %{covered} de %{total} · sin marca ms %{without}" + ru: "с лентой %{covered} из %{total} · годных %{fit} · без мс-штампа %{without}" + en: "tape for %{covered} of %{total} · reproduced %{fit} · no ms stamp %{without}" + es: "cinta en %{covered} de %{total} · reproducidas %{fit} · sin marca ms %{without}" analytics.ticks.coverage_service: ru: "служебных %{n}" en: "service rows %{n}" @@ -1720,18 +1720,18 @@ analytics.ticks.coverage_untunable: ru: "вне тюнинга %{n}" en: "outside tuning %{n}" es: "fuera del ajuste %{n}" -analytics.ticks.only_with_tape: - ru: "только с лентой" - en: "with tape only" - es: "solo con cinta" -analytics.ticks.only_with_tape_tip: - ru: "Показывать только сделки, чьё окно покрыто лентой, — это и есть выборка, по которой считаются варианты и идёт подбор. Воспроизводит ли модель сделку, показывает колонка «модель»; выборку это не сужает" - en: "Show only the trades whose window the tape covers — that is the sample the variants and the search run on. Whether the model reproduces a trade is the \"model\" column; it does not narrow the sample" - es: "Mostrar solo las operaciones cuya ventana cubre la cinta: esa es la muestra sobre la que se calculan las variantes y el ajuste. Si el modelo reproduce la operación lo indica la columna «modelo»; no reduce la muestra" -analytics.ticks.none_with_tape: - ru: "Сделок с лентой пока нет — %{hidden} скрыто галкой «только с лентой»" - en: "No trades with tape yet — %{hidden} hidden by \"with tape only\"" - es: "Aún no hay operaciones con cinta — %{hidden} ocultas por «solo con cinta»" +analytics.ticks.only_fit: + ru: "только годные" + en: "reproduced only" + es: "solo reproducidas" +analytics.ticks.only_fit_tip: + ru: "Показывать только выборку, по которой считаются варианты и идёт подбор: окно сделки покрыто лентой, и модель воспроизводит сделку на её собственных настройках (✓ в колонке «модель», вход — ✓ или не моделируется). Сделку, которую модель не воспроизводит, — стоп по стакану, правило, которого у модели нет, тейк без записи ядра, — подбор не берёт: ответ модели для её варианта не ответ" + en: "Show only the sample the variants and the search run on: the tape covers the trade's window, and the model reproduces the trade on its own settings (✓ in the \"model\" column, the entry ✓ or not modelled). A trade the model does not reproduce — a stop on the book, a rule the model does not have, a take the core left no record of — is out of the search: what the model answers for a variant of it is not an answer" + es: "Mostrar solo la muestra sobre la que se calculan las variantes y el ajuste: la cinta cubre la ventana de la operación y el modelo la reproduce con sus propios ajustes (✓ en la columna «modelo», la entrada ✓ o no modelada). Una operación que el modelo no reproduce — un stop por el libro, una regla que el modelo no tiene, un take sin registro del núcleo — queda fuera del ajuste: lo que el modelo responde para una variante suya no es una respuesta" +analytics.ticks.none_fit: + ru: "Годных сделок пока нет — %{hidden} скрыто галкой «только годные»" + en: "No reproduced trades yet — %{hidden} hidden by \"reproduced only\"" + es: "Aún no hay operaciones reproducidas — %{hidden} ocultas por «solo reproducidas»" analytics.ticks.empty_left_out: ru: "В периоде нет сделок, по которым ось может считать: без мс-штампа %{without} (старые строки реплики или ядро до штампов — лента по ним не восстанавливается), служебных %{service} (фандинг, ликвидации, объединённые продажи, без стратегии, продажа сверх купленного количества), вне тюнинга %{untunable} (ручные продажи, стратегии без торгового правила)." en: "The period holds no trade the axis can read: %{without} without millisecond stamps (older replica rows, or a core before the stamps — no tape can be tied to them), %{service} service rows (funding, liquidations, joined sells, no strategy, a sale bigger than its entry), %{untunable} outside tuning (manual sells, strategies without a trading rule)." @@ -1821,13 +1821,13 @@ analytics.ticks.model_tip: en: "Entry: model vs fact %{entry} · Exit: %{exit}" es: "Entrada: modelo vs hecho %{entry} · Salida: %{exit}" analytics.ticks.subset: - ru: "Факт · с лентой" - en: "Fact · with tape" - es: "Hecho · con cinta" + ru: "Факт · годные" + en: "Fact · reproduced" + es: "Hecho · reproducidas" analytics.ticks.subset_sub: - ru: "по %{n} из %{m} · вход ✓ %{entry} · выход ✓ %{exit}" - en: "%{n} of %{m} · entry ✓ %{entry} · exit ✓ %{exit}" - es: "%{n} de %{m} · entrada ✓ %{entry} · salida ✓ %{exit}" + ru: "по %{n} из %{m} с лентой · вход ✓ %{entry} · выход ✓ %{exit}" + en: "%{n} of %{m} with tape · entry ✓ %{entry} · exit ✓ %{exit}" + es: "%{n} de %{m} con cinta · entrada ✓ %{entry} · salida ✓ %{exit}" analytics.ticks.horizon: ru: " · выход ≤ %{h} после закрытия" en: " · exit ≤ %{h} past the close" @@ -1877,9 +1877,9 @@ analytics.ticks.var_n: en: "V%{n}" es: "V%{n}" analytics.ticks.var_sub: - ru: "по %{n} из %{m} с лентой" - en: "%{n} of %{m} with tape" - es: "%{n} de %{m} con cinta" + ru: "по %{n} из %{m} годных" + en: "%{n} of %{m} reproduced" + es: "%{n} de %{m} reproducidas" analytics.ticks.holdout: ru: "holdout: %{n} сделок, %{profit}" en: "holdout: %{n} trades, %{profit}" From 4c17cbd3e7937f8e6d1e04de2e24502e9c66d8c0 Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 10:51:33 +0200 Subject: [PATCH 20/51] feat(tuner): open the model at the entry order's creation, re-place a retreating MoonShot only past the corridor's far edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../moon-core/src/db/analytics/query/mod.rs | 6 + crates/moon-core/src/db/tape_owners.rs | 9 +- crates/moon-core/src/db/tape_owners/tests.rs | 1 + .../src/db/tuner/ticks/calibrate/tests.rs | 3 + crates/moon-core/src/db/tuner/ticks/deals.rs | 11 +- .../src/db/tuner/ticks/deals/tests.rs | 45 ++++ .../src/db/tuner/ticks/line/tests.rs | 3 + crates/moon-core/src/db/tuner/ticks/mod.rs | 124 +++++++++-- crates/moon-core/src/db/tuner/ticks/mshot.rs | 204 ++++++++++++------ crates/moon-core/src/db/tuner/ticks/record.rs | 61 ++++-- .../src/db/tuner/ticks/record/tests.rs | 45 +++- .../src/db/tuner/ticks/search/tests.rs | 3 + .../src/db/tuner/ticks/stats/tests.rs | 3 + crates/moon-core/src/db/tuner/ticks/tests.rs | 123 +++++++++++ .../src/db/tuner/ticks/tests/real_data.rs | 68 ++++-- .../src/db/tuner/ticks/tests/required.rs | 69 +++++- .../src/analytics/tuner/ticks/fetch.rs | 15 +- .../analytics/tuner/ticks/fetch/autoload.rs | 6 +- .../src/analytics/tuner/ticks/fetch/job.rs | 33 +-- .../analytics/tuner/ticks/fetch/job/tests.rs | 4 +- .../src/analytics/tuner/ticks/load.rs | 68 +++--- .../src/analytics/tuner/ticks/rows/tests.rs | 3 + .../src/analytics/tuner/ticks/state.rs | 6 +- .../src/settings/storage/trades_cleanup.rs | 73 +++++-- .../settings/storage/trades_cleanup/tests.rs | 78 +++++-- 25 files changed, 853 insertions(+), 211 deletions(-) diff --git a/crates/moon-core/src/db/analytics/query/mod.rs b/crates/moon-core/src/db/analytics/query/mod.rs index 0fae2bce..ce0905d9 100644 --- a/crates/moon-core/src/db/analytics/query/mod.rs +++ b/crates/moon-core/src/db/analytics/query/mod.rs @@ -517,6 +517,12 @@ const UNIFIED_COLS: &[&str] = &[ "reportuid", "buydatems", "closedatems", + // The entry order's creation and the corridor the core last saved for it — the same tuner + // starts its MoonShot replay at the creation. Cores file them since 2026-09-21 and never + // backfill; NULL on an older replica, zero on an older row. + "buysetdatems", + "buycorridordown", + "buycorridorup", ]; /// Money projection resolved by quote coverage before one analytical scan. diff --git a/crates/moon-core/src/db/tape_owners.rs b/crates/moon-core/src/db/tape_owners.rs index f3f9fd6a..bf01a724 100644 --- a/crates/moon-core/src/db/tape_owners.rs +++ b/crates/moon-core/src/db/tape_owners.rs @@ -22,6 +22,10 @@ pub struct TapeOwner { pub buy: ReportStamp, /// Exit stamp, same caveat. pub close: ReportStamp, + /// The entry order's creation (`buysetdatems`), core-local milliseconds like the entry's — + /// the tuner fetches a trade's tape from there (`tuner::ticks::model_window_at`), so the + /// cleanup must claim from there too. `None` on rows and replicas that do not carry it. + pub buy_set_ms: Option, pub strategy_id: i64, /// `sellreason` as the core wrote it. pub sell_reason: String, @@ -87,11 +91,13 @@ fn read_rows( } }; let sql = format!( - "SELECT core_uid, coin, buydate, closedate, {buy_ms}, {close_ms}, strategyid, sellreason + "SELECT core_uid, coin, buydate, closedate, {buy_ms}, {close_ms}, strategyid, sellreason, + {buy_set_ms} FROM {table} WHERE closedate > 0 AND closedate >= ?1 AND buydate <= ?2", buy_ms = column("buydatems"), close_ms = column("closedatems"), + buy_set_ms = column("buysetdatems"), table = rep::TABLE, ); let fail = |e: rusqlite::Error| super::read_fail::read_fail(CTX, e); @@ -109,6 +115,7 @@ fn read_rows( close: ReportStamp::resolve(close_s, close_ms), strategy_id: r.get::<_, Option>(6)?.unwrap_or(0), sell_reason: r.get::<_, Option>(7)?.unwrap_or_default(), + buy_set_ms: r.get::<_, Option>(8)?.filter(|&set| set > 0), kind: String::new(), }) }) diff --git a/crates/moon-core/src/db/tape_owners/tests.rs b/crates/moon-core/src/db/tape_owners/tests.rs index 5e151512..76f2b0a1 100644 --- a/crates/moon-core/src/db/tape_owners/tests.rs +++ b/crates/moon-core/src/db/tape_owners/tests.rs @@ -82,6 +82,7 @@ fn is_tunable_follows_the_axis_filter() { close: ReportStamp::Seconds(2), strategy_id, sell_reason: sell_reason.into(), + buy_set_ms: None, kind: kind.into(), }; assert!(owner(42, "MoonShot", "Sell Price").is_tunable()); diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs index 44a0f516..174eca8d 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs @@ -29,6 +29,9 @@ fn deal() -> Deal { step_lag_ms: 0.0, stop_anchor: None, own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index abe71c20..b2a82280 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -116,7 +116,8 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { "SELECT o.\"reportuid\", o.\"core_uid\", o.\"strategyid\", o.\"coin\", o.\"buydatems\", o.\"closedatems\", o.\"buyprice\", o.\"sellprice\", o.\"spentbtc\", o.\"isshort\", o.\"sellreason\", COALESCE(o.pnl, 0), {deltas}, - o.\"core_name\", o.\"quantity\", o.\"boughtq\" + o.\"core_name\", o.\"quantity\", o.\"boughtq\", + o.\"buysetdatems\", o.\"buycorridordown\", o.\"buycorridorup\" FROM {src}" ); let mut stmt = conn.prepare(&sql).map_err(|e| read_fail_on(conn, CTX, e))?; @@ -182,6 +183,10 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { *slot = num(12 + offset)?; } let report_uid = int(0)?; + // Zero, NULL and a creation after the fill all read as "not filed" (`Deal::buy_set_ms`). + let buy_set_ms = Some(int(name_at + 3)?).filter(|&set| set > 0 && set <= buy_ms); + let corridor = Some((num(name_at + 4)?, num(name_at + 5)?)) + .filter(|&(down, up)| down > 0.0 && up > 0.0); out.deals.push(Deal { report_uid, core_uid: int(1)? as u64, @@ -196,6 +201,8 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { .map_err(fail)? .unwrap_or_default(), buy_ms, + buy_set_ms, + corridor, close_ms, buy_price: num(6)?, sell_price: num(7)?, @@ -209,6 +216,8 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { tick: None, pre_spike_ask: None, archived_take: None, + // Filled with the model inputs, once the archive is in (`record::prepare_deal`). + entry_placed: None, // Filled by `overlay_hook_detect` off the raw report row's comment. hook_depth_pct: None, hook_stated_take_pct: None, diff --git a/crates/moon-core/src/db/tuner/ticks/deals/tests.rs b/crates/moon-core/src/db/tuner/ticks/deals/tests.rs index 731bcaac..aafa9956 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals/tests.rs @@ -100,6 +100,51 @@ fn the_usdt_overlay_fills_profit_by_report_uid() { ); } +/// The entry order's creation and its saved corridor come with the row where the core filed +/// them; a zero, a creation after the fill and a half corridor read as not filed, and a replica +/// without the columns (the fixture above) reads them all as absent. +#[test] +fn the_orders_creation_and_corridor_are_read_where_filed() { + let conn = Connection::open_in_memory().expect("in-memory database"); + conn.execute_batch( + "CREATE TABLE orders_rep( + reportuid INTEGER, core_uid INTEGER, strategyid INTEGER, coin TEXT, + buydate INTEGER, closedate INTEGER, buydatems INTEGER, closedatems INTEGER, + buyprice REAL, sellprice REAL, spentbtc REAL, profitbtc REAL, isshort INTEGER, + sellreason TEXT, basecurrency INTEGER, buysetdatems INTEGER, + buycorridordown REAL, buycorridorup REAL + ); + INSERT INTO orders_rep VALUES + (21, 7, 42, 'ACE', 100, 200, 100000, 200000, 99.0, 100.0, 1000.0, 10.0, 0, + 'Sell Price', 1, 95000, 99.6, 98.1), + (22, 7, 42, 'ACE', 110, 210, 110000, 210000, 99.0, 100.0, 1000.0, 10.0, 0, + 'Sell Price', 1, 0, 0.0, 0.0), + (23, 7, 42, 'ACE', 120, 220, 120000, 220000, 99.0, 100.0, 1000.0, 10.0, 0, + 'Sell Price', 1, 120001, 99.6, 0.0);", + ) + .expect("fixture"); + let (q, src) = tuner_source_on(&conn, &scope()).expect("source"); + let read = read_on(&conn, &q, &src).expect("read"); + let by_uid = |uid| { + read.deals + .iter() + .find(|d| d.report_uid == uid) + .expect("deal") + }; + assert_eq!(by_uid(21).buy_set_ms, Some(95_000)); + assert_eq!(by_uid(21).corridor, Some((99.6, 98.1))); + assert_eq!((by_uid(22).buy_set_ms, by_uid(22).corridor), (None, None)); + assert_eq!((by_uid(23).buy_set_ms, by_uid(23).corridor), (None, None)); + let old_conn = replica(); + let (old_q, old_src) = tuner_source_on(&old_conn, &scope()).expect("source"); + let old = read_on(&old_conn, &old_q, &old_src).expect("read"); + assert!( + old.deals + .iter() + .all(|d| d.buy_set_ms.is_none() && d.corridor.is_none()) + ); +} + #[test] fn a_replica_without_the_stamp_columns_yields_no_deals() { let conn = Connection::open_in_memory().expect("in-memory database"); diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index 322ba889..9eceadb9 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -44,6 +44,9 @@ fn deal(short: bool) -> Deal { step_lag_ms: 0.0, stop_anchor: None, own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index c865e6d7..fbba152d 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -23,7 +23,7 @@ //! checked against the live `strategies.sqlite` field names on 2026-09-20. use crate::feed::types::Tick; -use crate::market::trade_replay::Coverage; +use crate::market::trade_replay::{Coverage, ReplayWindow, replay_window_ms}; pub mod calibrate; pub mod deals; @@ -45,7 +45,7 @@ pub use exit::{ExitModel, ExitParams, archived_pre_spike_ask, archived_take, tak pub use hook::{HookDetect, KIND_MOONHOOK, hook_take_pct, parse_hook_detect}; pub use mshot::{MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; -pub use record::{StopAnchor, fit_for_search, prepare_deal}; +pub use record::{OwnLines, StopAnchor, entry_placement, fit_for_search, prepare_deal}; pub use scope::{is_service_row, is_tunable}; pub use search::{PreparedDeal, SearchParams, SearchResult, suggest, variant_tally}; pub use stats::{fact_stats, stats_of}; @@ -165,6 +165,17 @@ pub struct Deal { /// `buydatems` — the fill of the entry, Unix ms. Rows without a millisecond stamp are not /// deals for this axis; the caller drops them and counts them. pub buy_ms: i64, + /// `buysetdatems` — the moment the core CREATED the entry order, Unix ms on the same clock + /// as `buy_ms`. Filed by cores since 2026-09-21 and never backfilled; `None` on older rows, + /// on a zero, and on a stamp after the fill, which no order can have. + pub buy_set_ms: Option, + /// `buycorridordown` / `buycorridorup` — the entry corridor the core last saved, as absolute + /// prices under their own names: `Down` is the edge a falling price crosses, `Up` the one a + /// rising price crosses, whatever their numeric order. `None` unless both are prices. The + /// model does not replay it — the core saves it once, at a moment the report does not name — + /// but its width is the corridor's own, so the `real_data` bench holds the model's + /// `MShotPriceMin` … `2 · MShotPrice − MShotPriceMin` band against it on every run. + pub corridor: Option<(f64, f64)>, /// `closedatems` — the fill of the exit, Unix ms. pub close_ms: i64, pub buy_price: f64, @@ -205,6 +216,10 @@ pub struct Deal { /// starts. FLOCK 2026-09-21 (HookN0, short): the model's `SellPrice` take at −1.0 %, the /// core's at −2.2 %, every PriceDown step then a different level. pub archived_take: Option, + /// The level the entry order stood at when the core created it — where a replay from the + /// creation places the fact's order ([`record::entry_placement`]); filled with the rest of + /// the model inputs, `None` before them and wherever the record does not prove it. + pub entry_placed: Option, /// The detect depth of a MoonHook trade, per cent, as the core wrote it into the report's /// `comment` — the base of that kind's take rule ([`hook::hook_take_pct`]). `None` for /// every other kind, and for a hook row whose comment the scan could not read. @@ -234,6 +249,76 @@ impl Deal { pub fn is_long(&self) -> bool { !self.is_short } + + /// When the entry order's life began, for a model that replays it whole ([`order_open_at`]). + pub fn order_open_ms(&self) -> Option { + order_open_at(self.buy_ms, self.buy_set_ms) + } +} + +/// When an entry order's life began, for a model that replays it whole: its creation stamp, +/// unless the order waited longer than [`ORDER_WAIT_CAP_MS`] — then its early life is not +/// fetched and the model starts where the tape does. One rule for every kind of the tuner, the +/// ones without an entry model too: their tape is fetched and kept for a model to come. +/// +/// Args: +/// buy_ms: The fill of the entry. +/// buy_set_ms: The order's creation, on the same clock (`buysetdatems`). +pub fn order_open_at(buy_ms: i64, buy_set_ms: Option) -> Option { + buy_set_ms.filter(|&set| set <= buy_ms && buy_ms - set <= ORDER_WAIT_CAP_MS) +} + +/// The longest wait of an entry order the tape is fetched for, from its creation to its fill. +/// MoonShot orders on this machine's reports (2026-09-23, 289 with a creation stamp) waited a +/// median 114 s, 280 s at the 90th percentile and hours at the 99th; the cap keeps the few that +/// wait for hours from asking the venue for hours of prints, and they replay as before. +pub const ORDER_WAIT_CAP_MS: i64 = 10 * 60_000; + +/// The replay window of a deal as the model needs it ([`model_window_at`] on the deal's own +/// stamps). +pub fn model_window(deal: &Deal, margin_ms: i64, long_position_ms: i64) -> Option { + model_window_at( + deal.order_open_ms(), + deal.buy_ms, + deal.close_ms, + margin_ms, + long_position_ms, + ) +} + +/// The replay window of a trade as the model needs it: from the entry order's creation +/// ([`order_open_at`]) through the close, one stretch. Where that stretch would be walked as its +/// two ends — the order's life plus the position outrun `long_position_ms` — the window opens at +/// the fill as before: an entry end centred on the creation would leave the fill itself between +/// the ends, where nothing is fetched. The tape cleanup claims by this same rule +/// (`trades_cleanup`), so what the tuner fetched is what it keeps. +/// +/// Args: +/// order_open_ms: The order's creation where a replay may start there ([`order_open_at`]). +/// buy_ms: The fill of the entry. +/// close_ms: The close. +/// margin_ms: The model's margin (`trade_replay::model_margin_ms`). +/// long_position_ms: The threshold the window is split by — the caller's, so every stage +/// of one row splits it the same way. +/// +/// Returns: +/// The window, or `None` when the stamps describe none. +pub fn model_window_at( + order_open_ms: Option, + buy_ms: i64, + close_ms: i64, + margin_ms: i64, + long_position_ms: i64, +) -> Option { + let with_threshold = |window: ReplayWindow| ReplayWindow { + long_position_ms, + ..window + }; + let from_creation = order_open_ms + .and_then(|open| replay_window_ms(open, close_ms, margin_ms)) + .map(with_threshold) + .filter(|w| w.close_ms - w.open_ms <= w.long_position_ms); + from_creation.or_else(|| replay_window_ms(buy_ms, close_ms, margin_ms).map(with_threshold)) } /// Where and when the modelled entry order filled. @@ -302,9 +387,10 @@ pub enum EntryParams { MoonShot(MshotParams), } -/// The tape must reach this far back before the buy for the corridor to have a run-up. The -/// replay worker walks exactly this much as part of the trade for a model's request -/// (`trade_replay::MODEL_PAD_MS`), so the two are one number. +/// The tape must reach this far back before the window's open — the entry order's creation, or +/// the buy — for the corridor to have a run-up. The replay worker walks exactly this much as +/// part of the trade for a model's request (`trade_replay::MODEL_PAD_MS`), so the two are one +/// number. pub const RUN_UP_MS: i64 = crate::market::trade_replay::MODEL_PAD_MS; /// The tape must reach this far past the close for the exit to have a tail: a line the fact @@ -314,26 +400,28 @@ pub const RUN_UP_MS: i64 = crate::market::trade_replay::MODEL_PAD_MS; /// into one. pub const TAIL_MS: i64 = crate::market::trade_replay::MODEL_PAD_MS; -/// The part of a deal's window the model cannot do without: the run-up before the buy through -/// the tail after the close, clipped to what the window asks for at all (a long position asks -/// only around its two ends; a model's window is built with a margin of at least the pads, see -/// `trade_replay::model_margin_ms`). The rest of the -/// window — the trail beyond the tail, the lead beyond the run-up — is served as far as the -/// tape goes: a venue's page budget runs out on the trail of a pumped coin long before the -/// margin, and a variant that outlives the tape is marked open at the window's end. +/// The part of a deal's window the model cannot do without: the run-up before the window's open +/// — the entry order's creation where [`model_window`] opened there, else the buy — through the +/// tail after the close, clipped to what the window asks for at all (a long position asks only +/// around its two ends; a model's window is built with a margin of at least the pads, see +/// `trade_replay::model_margin_ms`). Read off the window rather than the deal: the worker walks +/// the pads around the window's own open as part of the trade, and a requirement reaching past +/// them would name prints nobody was sure to fetch. The rest of the window — the trail beyond +/// the tail, the lead beyond the run-up — is served as far as the tape goes: a venue's page +/// budget runs out on the trail of a pumped coin long before the margin, and a variant that +/// outlives the tape is marked open at the window's end. /// /// Args: -/// deal: The deal, for its buy and close stamps. -/// spans: The window's focus spans, as asked from the worker. +/// window: The deal's window, as asked from the worker. /// /// Returns: /// The spans the held coverage must include for the deal to count as covered. -pub fn required_spans(deal: &Deal, spans: &Coverage) -> Coverage { +pub fn required_spans(window: &ReplayWindow) -> Coverage { Coverage::one(( - deal.buy_ms.saturating_sub(RUN_UP_MS), - deal.close_ms.saturating_add(TAIL_MS), + window.open_ms.saturating_sub(RUN_UP_MS), + window.close_ms.saturating_add(TAIL_MS), )) - .clip(spans) + .clip(&window.focus_spans()) } /// Run one trade through the entry and the exit model. diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index 5ea57f2f..04fac4c9 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -5,8 +5,13 @@ //! //! - the order stands `MShotPrice` % away from the reference price; the price may approach it //! down to `MShotPriceMin` % — closer than that, the order is re-placed at `MShotPrice` again -//! after `MShotReplaceDelay` seconds; when the price runs away so the order is farther than -//! `MShotPrice`, it is re-placed after `MShotRaiseWait` seconds; +//! after `MShotReplaceDelay` seconds; when the price runs away, it is re-placed after +//! `MShotRaiseWait` seconds. How far it may run is not what the FAQ's wording suggests: the +//! corridor the core saves with the report (`BuyCorridorDown` / `BuyCorridorUp`) is a band of +//! ORDER prices symmetric around the placement, from `MShotPriceMin` to `2 · MShotPrice − +//! MShotPriceMin` off the reference, modifiers included — to 0.05 % on 155 of 287 MoonShot +//! trades (2026-09-23), the rest wider by what the live deltas moved. So the order is re-placed +//! only past `2 · far − near`, not past `far`; //! - every `MShotAdd*Delta` adds `k · delta` to BOTH bounds, the delta's sign as the report //! carries it: the FAQ's `-10% + (-20 · 0.05) = -11%` is a coin UP 20 % on 3 h putting the //! order 1 % deeper (checked on the live tape on 2026-09-20: a long on ROSE, up 7 % / 11 % on @@ -43,9 +48,19 @@ //! ARX the same day: the core re-placed 0.3 s into the tape after a wait of 30 s, and the model //! waiting its own 30 s put the order one step too deep for the spike. Past the window the //! model is on its own, and the archive is what it is held against. +//! +//! What the report adds since 2026-09-21: the moment the core CREATED the order +//! (`Deal::order_open_ms`), and with it the whole of the order's life — where the record proves +//! the placement (`Deal::entry_placed`, see `record::entry_placement`: the archived line's first +//! point, or the buy price for an order the archive shows never moved). When the tape reaches +//! back to the creation the model replays that whole life: the order is placed at the creation +//! where the core placed it, and from then on the corridor is the model's own, with no blind +//! window and no archived move taken as given. A VARIANT is placed off the same reference by its +//! own bounds ([`MshotEntry::placement_at_creation`]) instead of inheriting the fact's level, +//! which is what a search over `MShotPrice` asks about. use super::verify::archived_replacements; -use super::{Deal, Deltas, Fill, reaches, snap_to_step}; +use super::{Deal, Deltas, EntryParams, Fill, reaches, snap_to_step}; use crate::feed::types::{Side, Tick}; /// Which price the order keeps its distance from (`MShotUsePrice`). @@ -225,6 +240,48 @@ impl<'a> MshotEntry<'a> { level } + /// The level the order stood at when the core created it, under these parameters. + /// + /// The fact's own level is the record's ([`Deal::entry_placed`]). A VARIANT's is the level + /// its own far bound places off the same reference: the fact's level stands the fact's far + /// bound off it, so the reference is read back from the level and this variant's bound + /// applied instead, through the same placement rule ([`Self::place`]). + /// + /// Whose parameters are the fact's is [`Deal::own_entry`]. A variant is always replayed on a + /// prepared deal (`record::prepare_deal` fills it), and the one caller without it — the + /// verdict — replays the trade's own parameters, so a deal without it is placed at the + /// fact's level as it stands. + /// + /// Args: + /// deal: The report row, with its model inputs. + /// far_pct: These parameters' far bound for the deal. + /// + /// Returns: + /// The level, or `None` when the record proves no placement or no reference can be read + /// back from it. + fn placement_at_creation(&self, deal: &Deal, far_pct: f64) -> Option { + let fact_level = deal.entry_placed.filter(|l| l.is_finite() && *l > 0.0)?; + let Some(EntryParams::MoonShot(own)) = deal.own_entry.as_ref() else { + return Some(fact_level); + }; + let (_, fact_far_pct) = own.bounds_pct(&deal.deltas); + if fact_far_pct == far_pct { + return Some(fact_level); + } + // The fact's level is its placement snapped AWAY from the reference (`place`): the level + // before the snap lay within one step of it toward the price, half a step on average, and + // the reference is read back from there — off the snapped level itself it would sit a + // half step too far and every variant with it. (`MShotMinusSatoshi` binds only on a + // corridor narrower than two steps and is not undone.) + let half_step = deal.tick.filter(|t| *t > 0.0).map_or(0.0, |t| t / 2.0); + let reference = if deal.is_long() { + (fact_level + half_step) / (1.0 - fact_far_pct / 100.0) + } else { + (fact_level - half_step) / (1.0 + fact_far_pct / 100.0) + }; + (reference.is_finite() && reference > 0.0).then(|| self.place(reference, far_pct, deal)) + } + /// Distance from the reference to the level, per cent, positive when the level is on the /// order's own side of the price (below for a long) and negative when the price has /// crossed it. @@ -258,6 +315,10 @@ impl<'a> MshotEntry<'a> { return None; } let (near_pct, far_pct) = self.params.bounds_pct(&deal.deltas); + // The corridor's far edge: a run-away re-places the order only past it (module doc). + // Where the bounds meet after the modifiers (`bounds_pct` lifts far to near) the band has + // no width: every move off the placement re-places, as before. + let retreat_pct = 2.0 * far_pct - near_pct; let raise_wait_ms = (self.params.raise_wait_s * 1000.0).max(0.0); let replace_delay_ms = (self.params.replace_delay_s * 1000.0).max(0.0); let latency_ms = self.params.latency_ms.max(0.0); @@ -268,65 +329,83 @@ impl<'a> MshotEntry<'a> { deal.is_long(), ); - // The archive's moves, and where the order stood when the tape begins: the last - // archived level at or before the first print, else the archive's first point (an - // order placed inside the tape starts at its own moment), else nothing. let first_print_ms = ticks[0].time_ms as i64; - let moves: Vec<(i64, f64)> = line - .filter(|l| !l.is_empty()) - .map(archived_replacements) - .unwrap_or_default(); - let start: Option<(i64, f64)> = moves - .iter() - .filter(|(t, _)| *t <= first_print_ms) - .max_by_key(|(t, _)| *t) - .or(moves.first()) - .copied(); - // The blind window: the core's moves archived inside it are applied as archived, - // because the wait behind each began before the tape did. Only moves after the start - // and before the fill count. - let blind_until_ms = first_print_ms + raise_wait_ms.max(replace_delay_ms) as i64; - let mut hints: Vec<(i64, f64)> = moves - .iter() - .filter(|(t, p)| { - start.is_none_or(|(s, _)| *t > s) - && *t > first_print_ms - && *t < blind_until_ms - && *t < deal.buy_ms - && *p > 0.0 - }) - .copied() - .collect(); - // The archive files a move as the old level's end and the new one's start, a few - // milliseconds apart and not always in that order; the hints are walked in time. - hints.sort_by_key(|(t, _)| *t); - let mut hints = hints.into_iter().peekable(); - - // Where the tape starts for the order: at the archived start, or at the first print. - // Prints before the start only feed the reference. - let start_ms = start.map(|(t, _)| t); let mut index = 0; - if let Some(start_ms) = start_ms { - while index < ticks.len() && (ticks[index].time_ms as i64) < start_ms { - reference.observe(&ticks[index]); - index += 1; + let mut hints: Vec<(i64, f64)> = Vec::new(); + // The whole life of the order, when the tape reaches back to its creation. + let created = deal + .order_open_ms() + .filter(|&created_ms| first_print_ms <= created_ms) + .and_then(|created_ms| Some((created_ms, self.placement_at_creation(deal, far_pct)?))); + // The exchange's level (what fills; `None` until the order reaches the book) and the + // core's (what the corridor is measured against); `pending` is a move the core made that + // the exchange has not seen yet. + let (mut exch_level, mut core_level, mut pending) = match created { + Some((created_ms, level)) => { + // Prints before the creation only feed the reference, and the placement reaches + // the book a latency after it, like any move. + while index < ticks.len() && (ticks[index].time_ms as i64) < created_ms { + reference.observe(&ticks[index]); + index += 1; + } + (None, level, Some((created_ms + latency_ms as i64, level))) } - } - // The exchange's level (what fills) and the core's (what the corridor is measured - // against); `pending` is a move the core made that the exchange has not seen yet. - let (mut exch_level, mut core_level) = match start { - Some((_, price)) if price > 0.0 => (price, price), - _ => { - // No archive: the order is placed off the first print, which then cannot fill - // it (it is the reference itself). - let first = ticks.get(index)?; - reference.observe(first); - index += 1; - let level = self.place(reference.price()?, far_pct, deal); - (level, level) + None => { + // The archive's moves, and where the order stood when the tape begins: the last + // archived level at or before the first print, else the archive's first point + // (an order placed inside the tape starts at its own moment), else nothing. + let moves: Vec<(i64, f64)> = line + .filter(|l| !l.is_empty()) + .map(archived_replacements) + .unwrap_or_default(); + let start: Option<(i64, f64)> = moves + .iter() + .filter(|(t, _)| *t <= first_print_ms) + .max_by_key(|(t, _)| *t) + .or(moves.first()) + .copied(); + // The blind window: the core's moves archived inside it are applied as + // archived, because the wait behind each began before the tape did. Only moves + // after the start and before the fill count. + let blind_until_ms = first_print_ms + raise_wait_ms.max(replace_delay_ms) as i64; + hints = moves + .iter() + .filter(|(t, p)| { + start.is_none_or(|(s, _)| *t > s) + && *t > first_print_ms + && *t < blind_until_ms + && *t < deal.buy_ms + && *p > 0.0 + }) + .copied() + .collect(); + // The archive files a move as the old level's end and the new one's start, a + // few milliseconds apart and not always in that order; the hints are walked in + // time. + hints.sort_by_key(|(t, _)| *t); + // Where the tape starts for the order: at the archived start, or at the first + // print. Prints before the start only feed the reference. + if let Some((start_ms, _)) = start { + while index < ticks.len() && (ticks[index].time_ms as i64) < start_ms { + reference.observe(&ticks[index]); + index += 1; + } + } + let level = match start { + Some((_, price)) if price > 0.0 => price, + _ => { + // No archive: the order is placed off the first print, which then + // cannot fill it (it is the reference itself). + let first = ticks.get(index)?; + reference.observe(first); + index += 1; + self.place(reference.price()?, far_pct, deal) + } + }; + (Some(level), level, None) } }; - let mut pending: Option<(i64, f64)> = None; + let mut hints = hints.into_iter().peekable(); let mut breach: Option<(Breach, i64)> = None; for tick in &ticks[index..] { @@ -343,14 +422,11 @@ impl<'a> MshotEntry<'a> { breach = None; } if let Some((_, level)) = pending.filter(|(apply_at, _)| t_ms >= *apply_at) { - exch_level = level; + exch_level = Some(level); pending = None; } - if reaches(price, exch_level, deal.is_long()) { - return Some(Fill { - t_ms, - price: exch_level, - }); + if let Some(level) = exch_level.filter(|&level| reaches(price, level, deal.is_long())) { + return Some(Fill { t_ms, price: level }); } reference.observe(tick); let Some(reference) = reference.price() else { @@ -359,7 +435,7 @@ impl<'a> MshotEntry<'a> { let distance = Self::distance_pct(reference, core_level, deal); let now = if distance < near_pct { Some(Breach::Approach) - } else if distance > far_pct { + } else if distance > retreat_pct { Some(Breach::Retreat) } else { None diff --git a/crates/moon-core/src/db/tuner/ticks/record.rs b/crates/moon-core/src/db/tuner/ticks/record.rs index f3066ca7..94f54cbf 100644 --- a/crates/moon-core/src/db/tuner/ticks/record.rs +++ b/crates/moon-core/src/db/tuner/ticks/record.rs @@ -96,27 +96,64 @@ impl StopAnchor { } } +/// The trade's own lines as the order archive answered for it. +#[derive(Clone, Copy, Debug, Default)] +pub struct OwnLines<'a> { + /// The archived Entry line, when the archive holds one. + pub entry: Option<&'a [(i64, f64)]>, + /// The archived Exit line, when the archive holds one. + pub exit: Option<&'a [(i64, f64)]>, + /// Whether the core answered for the trade WITH lines. Without them — an answer of "no + /// archive", or no answer yet — a missing Entry line proves nothing. + pub answered: bool, +} + /// Fill the model inputs the core's own record gives: the ask a MoonShot's take was lifted to, -/// read back off the take as placed, the take itself, the stop anchor, and the entry settings -/// the trade ran with ([`Deal::own_entry`]). +/// read back off the take as placed, the take itself, the level the entry order was placed at +/// ([`entry_placement`]), the stop anchor, and the entry settings the trade ran with +/// ([`Deal::own_entry`]). /// /// Args: /// deal: The trade, filled in place. /// entry: The entry parameters as of the buy. /// exit: The sell parameters as of the buy. -/// exit_points: The archived Exit line, when the archive holds it. -pub fn prepare_deal( - deal: &mut Deal, - entry: &EntryParams, - exit: &ExitParams, - exit_points: Option<&[(i64, f64)]>, -) { - deal.pre_spike_ask = archived_pre_spike_ask(exit_points, exit, deal.is_short); - deal.archived_take = archived_take(exit_points); - deal.stop_anchor = Some(StopAnchor::of(deal, exit, exit_points)); +/// lines: The trade's own archived lines. +pub fn prepare_deal(deal: &mut Deal, entry: &EntryParams, exit: &ExitParams, lines: OwnLines<'_>) { + deal.pre_spike_ask = archived_pre_spike_ask(lines.exit, exit, deal.is_short); + deal.archived_take = archived_take(lines.exit); + deal.entry_placed = entry_placement(deal, lines); + deal.stop_anchor = Some(StopAnchor::of(deal, exit, lines.exit)); deal.own_entry = Some(entry.clone()); } +/// The level the entry order stood at when the core created it ([`Deal::entry_placed`]). +/// +/// The core files an order's line only when the order moved: every archived Entry line holds a +/// move, starts at the creation stamp and ends at the fill (165 of 165 MoonShot lines of +/// 2026-09-23). So the level is the line's first point where the line starts at the creation, +/// and the buy price where the archive answered with lines and none of them is the entry's — +/// the order stood where it filled from its creation on. +/// +/// Args: +/// deal: The trade, for its creation stamp and buy price. +/// lines: The trade's own archived lines. +/// +/// Returns: +/// The level, or `None` — no creation stamp, no answer with lines (a missing line is then +/// no proof of anything), or a line that starts elsewhere than the stamp says. +pub fn entry_placement(deal: &Deal, lines: OwnLines<'_>) -> Option { + let created_ms = deal.order_open_ms()?; + let level = match lines.entry.filter(|l| !l.is_empty()) { + Some(points) => { + let &(first_ms, price) = points.iter().min_by_key(|(t, _)| *t)?; + ((first_ms - created_ms).abs() <= POINT_TIME_TOLERANCE_MS).then_some(price)? + } + None if lines.answered => deal.buy_price, + None => return None, + }; + (level.is_finite() && level > 0.0).then_some(level) +} + /// The deal as the verdict replays it: without what the fact proves ([`Deal::stop_anchor`], /// [`Deal::own_entry`]) — the verdict exists to test the model, and a model handed the fact /// passes by construction. diff --git a/crates/moon-core/src/db/tuner/ticks/record/tests.rs b/crates/moon-core/src/db/tuner/ticks/record/tests.rs index 044bdb1c..3605a555 100644 --- a/crates/moon-core/src/db/tuner/ticks/record/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -1,5 +1,5 @@ -//! The core's own record as the model's inputs: the stop anchor, the fallback lines, the rule -//! for the search's sample. +//! The core's own record as the model's inputs: the stop anchor, the fact's entry, the rule for +//! the search's sample. use super::*; use crate::db::tuner::ticks::line::walk; @@ -54,6 +54,9 @@ fn stopped() -> Deal { step_lag_ms: 0.0, stop_anchor: None, own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, } } @@ -199,6 +202,42 @@ fn the_verdict_never_leans_on_the_anchor() { assert_eq!((w.exit.t_ms, w.exit.price), (8_000, 97.0)); } +/// The entry order's placement at its creation: the archived line's first point when the line +/// starts at the stamp; the buy price when the archive answered with lines and none is the +/// entry's — the core files a line only when the order moved; nothing when the archive gave no +/// lines, when the line starts elsewhere, or without a stamp. +#[test] +fn the_entry_placement_is_what_the_record_proves() { + let mut d = stopped(); + d.buy_ms = 10_000; + d.buy_set_ms = Some(2_000); + let line = [(2_000, 98.5), (6_000, 99.4), (10_000, 100.0)]; + let answered = |entry| OwnLines { + entry, + exit: None, + answered: true, + }; + assert_eq!(entry_placement(&d, answered(Some(&line))), Some(98.5)); + assert_eq!( + entry_placement(&d, answered(None)), + Some(100.0), + "never moved" + ); + assert_eq!( + entry_placement(&d, OwnLines::default()), + None, + "no lines: no proof" + ); + let late = [(5_000, 98.5), (10_000, 100.0)]; + assert_eq!( + entry_placement(&d, answered(Some(&late))), + None, + "not the stamp's line" + ); + d.buy_set_ms = None; + assert_eq!(entry_placement(&d, answered(Some(&line))), None, "no stamp"); +} + /// A variant running the trade's own entry settings filled where the report says — the entry /// model is for the settings the core never ran. #[test] @@ -206,7 +245,7 @@ fn a_variant_running_the_trades_own_entry_fills_at_the_fact() { let mut d = stopped(); d.kind = "MoonShot".into(); let own = EntryParams::MoonShot(MshotParams::default()); - prepare_deal(&mut d, &own, &book(), None); + prepare_deal(&mut d, &own, &book(), OwnLines::default()); // A tape the corridor never reaches: the model alone would not fill at all. let ticks = vec![ tick(-20_000, 100.0), diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 228e7b1d..02027ac1 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -44,6 +44,9 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { step_lag_ms: 0.0, stop_anchor: None, own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, }; let t0 = deal.buy_ms; let ticks: Vec = vec![ diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index e64893ad..74c35c24 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -27,6 +27,9 @@ fn deal(pnl: f64, spent: f64) -> Deal { step_lag_ms: 0.0, stop_anchor: None, own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index e0882db6..f2d61161 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -54,6 +54,9 @@ fn deal() -> Deal { step_lag_ms: 0.0, stop_anchor: None, own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, } } @@ -292,6 +295,123 @@ fn leaving_and_re_entering_the_corridor_resets_the_wait() { assert!((fill.price - 99.0).abs() < 1e-9); } +/// The corridor the core saves is symmetric around the placement: a run-away re-places the order +/// only past `2 · far − near` — 1.5 % on the 1 % / 0.5 % corridor — not past `far`. +#[test] +fn a_run_away_re_places_the_order_only_past_the_corridors_far_edge() { + // Level 99 off 100. At 100.4 the order is 1.39 % off: inside the corridor, it stays, and + // the spike to 99 fills it there. + let ticks = tape(&[(0, 100.0), (1_000, 100.4), (2_000, 99.0)]); + let fill = fill_of(&deal(), &ticks, &mshot()).expect("filled"); + assert!((fill.price - 99.0).abs() < 1e-9, "{fill:?}"); + // At 100.6 it is 1.59 % off: re-placed at 100.6 · 0.99 ≈ 99.594 (the tape's `f32` 100.6), + // which the spike fills. + let ticks = tape(&[(0, 100.0), (1_000, 100.6), (2_000, 99.5)]); + let fill = fill_of(&deal(), &ticks, &mshot()).expect("filled"); + let re_placed = f64::from(100.6_f32) * 0.99; + assert!((fill.price - re_placed).abs() < 1e-9, "{fill:?}"); +} + +// ---- entry: the order's whole life, from its creation -------------------------------------- + +/// A deal the core stamped with its order's creation at `created_ms`, the record proving the +/// order stood at the buy price from then on (`record::entry_placement`). +fn stamped(created_ms: i64) -> Deal { + let d = deal(); + Deal { + buy_set_ms: Some(created_ms), + entry_placed: Some(d.buy_price), + ..d + } +} + +/// With no archived line the order never moved: it stood at the buy price from its creation, +/// and reached the book a latency after it — a print at the level before then fills nothing. +/// (A 10 s replace delay keeps that print's approach from moving the order.) Unstamped, the same +/// tape places the order off its first print, on the book at once, and the same print fills it. +#[test] +fn a_stamped_order_stands_at_the_buy_price_from_its_creation() { + let params = MshotParams { + replace_delay_s: 10.0, + ..mshot() + }; + let ticks = tape(&[ + (0, 100.0), + (1_000, 100.0), + (2_050, 99.0), + (3_000, 100.0), + (9_000, 99.0), + ]); + let fill = fill_of(&stamped(2_000), &ticks, ¶ms).expect("filled"); + assert_eq!((fill.t_ms, fill.price), (9_000, 99.0)); + let unstamped = fill_of(&deal(), &ticks, ¶ms).expect("filled"); + assert_eq!((unstamped.t_ms, unstamped.price), (2_050, 99.0)); +} + +/// The placement is the record's: the order stands where it proves the core placed it, and from +/// then on the corridor is the model's own — the archived line is not replayed on top. Without a +/// proven placement, or with a tape that starts after the creation, the stamp changes nothing. +#[test] +fn a_stamped_order_starts_at_the_records_placement_or_not_at_all() { + let ticks = tape(&[(0, 100.0), (2_500, 99.2), (3_000, 98.5)]); + let placed = Deal { + entry_placed: Some(98.5), + ..stamped(2_000) + }; + let fill = fill_of(&placed, &ticks, &mshot()).expect("filled"); + assert_eq!((fill.t_ms, fill.price), (3_000, 98.5)); + let unproven = Deal { + entry_placed: None, + ..stamped(2_000) + }; + assert_eq!( + fill_of(&unproven, &ticks, &mshot()), + fill_of(&deal(), &ticks, &mshot()) + ); + let late_tape = tape(&[(2_500, 99.2), (3_000, 98.5)]); + assert_eq!( + fill_of(&placed, &late_tape, &mshot()), + fill_of(&deal(), &late_tape, &mshot()) + ); +} + +/// A variant is placed at the creation off the reference the fact's level stood on, by its own +/// far bound: the fact at 99 on a 1 % bound stood off 100, so a 2 % variant stands at 98 and +/// fills on the deeper print. Without the fact's own parameters on the deal — the verdict's +/// case, which replays those very parameters — the order stands at the fact's level. +#[test] +fn a_variant_is_placed_at_the_creation_by_its_own_bound() { + let mut d = stamped(2_000); + d.own_entry = Some(EntryParams::MoonShot(mshot())); + let deeper = MshotParams { + price_pct: 2.0, + ..mshot() + }; + let ticks = tape(&[(0, 100.0), (2_500, 99.5), (3_000, 99.0), (4_000, 98.0)]); + let fill = fill_of(&d, &ticks, &deeper).expect("filled deeper"); + assert_eq!((fill.t_ms, fill.price), (4_000, 98.0)); + let fact = fill_of(&stamped(2_000), &ticks, &deeper).expect("filled"); + assert_eq!((fact.t_ms, fact.price), (3_000, 99.0)); +} + +/// On a price grid the fact's level is its placement snapped away from the price, so the +/// reference is read back from half a step toward it: the fact at 99 on a 1-step grid stood off +/// 100 … 101, 100.5 at the middle, and a 1.3 % variant stands at 99.2 → 99 — not at the 98 a +/// reference read off the snapped 99 itself would give. +#[test] +fn a_variants_reference_is_read_back_from_the_middle_of_the_step() { + let mut d = stamped(2_000); + d.tick = Some(1.0); + d.own_entry = Some(EntryParams::MoonShot(mshot())); + let variant = MshotParams { + price_pct: 1.3, + ..mshot() + }; + let ticks = tape(&[(0, 100.5), (2_500, 99.0)]); + let fill = fill_of(&d, &ticks, &variant).expect("filled at the variant's level"); + assert_eq!((fill.t_ms, fill.price), (2_500, 99.0)); +} + // ---- entry: modifiers, the price grid, the reference -------------------------------------- #[test] @@ -1168,6 +1288,9 @@ fn hook_deal() -> Deal { step_lag_ms: 0.0, stop_anchor: None, own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, ..deal() } } diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index fd70f0b5..feaccbb9 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -27,17 +27,18 @@ use crate::db::analytics::Query; use crate::db::order_traces::{TraceEntry, read_many}; use crate::db::tuner::strategy_values_at; use crate::feed::report_traces::ArchivedLineKind; -use crate::market::trade_replay::{Coverage, TickQuery, query_held, replay_window_ms}; +use crate::market::trade_replay::{Coverage, TickQuery, long_position_ms, query_held}; use crate::symbol::{coin_match_key, coin_of_market}; -/// Every point of an archived entry line and of an exit line. -type ArchivedLines = (Option>, Option>); +/// Every point of an archived entry line and of an exit line, and whether the core answered +/// for the deal with lines at all. +type ArchivedLines = (Option>, Option>, bool); /// The points of the deal's own entry line and of its own exit line, when the archive holds -/// them. +/// them, and whether it answered with lines — as the axis reads them (`load.rs::ArchivedLines`). fn archived_lines(deal: &Deal) -> ArchivedLines { let Ok(entries) = read_many(deal.core_uid, &[deal.report_uid]) else { - return (None, None); + return (None, None, false); }; match entries.get(&deal.report_uid) { Some(TraceEntry::Lines(lines)) => { @@ -49,9 +50,9 @@ fn archived_lines(deal: &Deal) -> ArchivedLines { .iter() .find(|l| l.own && l.kind == ArchivedLineKind::Exit) .map(|l| l.points.iter().map(|&(t, p)| (t as i64, p)).collect()); - (entry, exit) + (entry, exit, true) } - _ => (None, None), + _ => (None, None, false), } } @@ -180,7 +181,7 @@ fn core_step_lags( if !is_tunable(&deal.kind, &deal.sell_reason) { continue; } - let (_, Some(points)) = archived_lines(deal) else { + let (_, Some(points), _) = archived_lines(deal) else { continue; }; let Some(values) = @@ -268,6 +269,11 @@ fn real_data_reproduction() { // reads as that in the summary rather than as a scope without tape. let mut no_venue = 0usize; let (mut fit_n, mut own_n, mut own_close) = (0usize, 0usize, 0usize); + // MoonShot entries replayed from the order's creation (`mshot`, `Deal::entry_placed`), and + // how many of those the model reproduced. + let (mut created_n, mut created_hits, mut stamped) = (0usize, 0usize, 0usize); + // MoonShot trades whose saved corridor (`Deal::corridor`) the model's band matches in width. + let (mut corridor_n, mut corridor_hits) = (0usize, 0usize); let (mut own_sum, mut fact_sum) = (0.0f64, 0.0f64); for mut deal in read.deals { *kinds_seen.entry(deal.kind.clone()).or_default() += 1; @@ -290,7 +296,7 @@ fn real_data_reproduction() { let coin_key = coin_match_key(&deal.coin); // The same window and the same gate the axis applies (`load.rs::replay_row`): the // worker's coverage must include `required_spans`, whatever the prints say. - let Some(window) = replay_window_ms(deal.buy_ms, deal.close_ms, margin_ms) else { + let Some(window) = model_window(&deal, margin_ms, long_position_ms()) else { continue; }; let spans = window.focus_spans(); @@ -315,12 +321,12 @@ fn real_data_reproduction() { } ticks.sort_by(|a, b| a.time_ms.total_cmp(&b.time_ms)); ticks.dedup_by(|a, b| a.time_ms == b.time_ms && a.price == b.price && a.qty == b.qty); - if ticks.is_empty() || !covered.covers(&required_spans(&deal, &spans)) { + if ticks.is_empty() || !covered.covers(&required_spans(&window)) { continue; } with_tape += 1; deal.tick = infer_tick(&ticks); - let (entry_line, exit_points) = archived_lines(&deal); + let (entry_line, exit_points, answered) = archived_lines(&deal); let sv = StrategyValues { values: &values, defaults: &defaults, @@ -332,7 +338,30 @@ fn real_data_reproduction() { }; let exit = exit_params(&sv); deal.step_lag_ms = core_lags.get(&deal.core_uid).copied().unwrap_or(0.0); - prepare_deal(&mut deal, &entry, &exit, exit_points.as_deref()); + prepare_deal( + &mut deal, + &entry, + &exit, + OwnLines { + entry: entry_line.as_deref(), + exit: exit_points.as_deref(), + answered, + }, + ); + // The corridor the core saved against the model's band, `near` … `2 · far − near` off one + // reference (`mshot`): the ratio of its edges is the band's width whatever the reference. + if let (Some((down, up)), EntryParams::MoonShot(params)) = (deal.corridor, &entry) { + let (near, far) = params.bounds_pct(&deal.deltas); + let (a, b) = (near / 100.0, (2.0 * far - near) / 100.0); + let predicted = if deal.is_long() { + (1.0 - a) / (1.0 - b) + } else { + (1.0 + b) / (1.0 + a) + }; + corridor_n += 1; + corridor_hits += + usize::from((down.max(up) / down.min(up) / predicted - 1.0).abs() <= 0.0005); + } // The modelled line beside the archive's moves, for the eye. if let (Some(fill), Some(moves)) = ( simulate(&deal, &ticks, &entry, &exit, entry_line.as_deref()).fill, @@ -488,6 +517,17 @@ fn real_data_reproduction() { if let Some(ok) = entry_verdict { entry_n += 1; entry_hits += usize::from(ok); + // Replayed from the creation: the record proved the placement and the tape reaches + // back to it — the condition `mshot` starts the order there on. + stamped += usize::from(deal.order_open_ms().is_some()); + let from_creation = deal.entry_placed.is_some() + && deal + .order_open_ms() + .is_some_and(|created| (ticks[0].time_ms as i64) <= created); + if from_creation { + created_n += 1; + created_hits += usize::from(ok); + } } if let Some(ok) = archived.exit { exit_n += 1; @@ -499,6 +539,10 @@ fn real_data_reproduction() { "with tape: {with_tape} · entry ✓ {entry_hits}/{entry_n} · exit ✓ {exit_hits}/{exit_n} · \ skipped, core venue unknown: {no_venue}" ); + eprintln!( + "entry from the order's creation: ✓ {created_hits}/{created_n} · stamped entries {stamped}" + ); + eprintln!("saved corridors the model's band matches to 0.05 %: {corridor_hits}/{corridor_n}"); let mut unfit: Vec<(String, usize)> = unfit.into_iter().collect(); unfit.sort_by_key(|u| std::cmp::Reverse(u.1)); eprintln!("fit for the search: {fit_n} of {with_tape} · left out: {unfit:?}"); diff --git a/crates/moon-core/src/db/tuner/ticks/tests/required.rs b/crates/moon-core/src/db/tuner/ticks/tests/required.rs index e3befded..389337c3 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/required.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/required.rs @@ -1,4 +1,4 @@ -use super::super::{Deal, RUN_UP_MS, TAIL_MS, required_spans}; +use super::super::{Deal, ORDER_WAIT_CAP_MS, RUN_UP_MS, TAIL_MS, model_window, required_spans}; use super::deal; use crate::market::trade_replay::{Coverage, replay_window_ms}; @@ -19,8 +19,7 @@ fn short_deal_requires_the_run_up_through_the_tail_only() { let buy = 100 * MINUTE_MS; let close = buy + 20_000; let window = replay_window_ms(buy, close, 15 * MINUTE_MS).expect("window"); - let spans = window.focus_spans(); - let required = required_spans(&deal_at(buy, close), &spans); + let required = required_spans(&window); assert_eq!(required.spans(), &[(buy - RUN_UP_MS, close + TAIL_MS)]); let trail_cut = Coverage::one((buy - RUN_UP_MS, close + TAIL_MS)); assert!( @@ -44,7 +43,7 @@ fn long_position_and_zero_margin_require_only_what_the_window_asks() { let window = replay_window_ms(buy, close, margin).expect("window"); let spans = window.focus_spans(); assert!(spans.is_split()); - let required = required_spans(&deal_at(buy, close), &spans); + let required = required_spans(&window); let half = margin / 2; assert_eq!( required.spans(), @@ -55,11 +54,63 @@ fn long_position_and_zero_margin_require_only_what_the_window_asks() { ); assert!(spans.covers(&required)); let close = buy + 20_000; - let bare = replay_window_ms(buy, close, 0) - .expect("window") - .focus_spans(); + let bare = replay_window_ms(buy, close, 0).expect("window"); + assert_eq!(required_spans(&bare).spans(), &[(buy, close)]); +} + +/// An order created two minutes before its fill is replayed from its creation: the window opens +/// there, and the run-up is owed before it, not before the fill. +#[test] +fn the_model_window_opens_at_the_orders_creation() { + let buy = 100 * MINUTE_MS; + let close = buy + 20_000; + let mut d = deal_at(buy, close); + d.buy_set_ms = Some(buy - 2 * MINUTE_MS); + let window = model_window(&d, MINUTE_MS, 60 * MINUTE_MS).expect("window"); + assert_eq!( + (window.open_ms, window.close_ms), + (buy - 2 * MINUTE_MS, close) + ); + assert_eq!( + required_spans(&window).spans(), + &[(buy - 2 * MINUTE_MS - RUN_UP_MS, close + TAIL_MS)] + ); +} + +/// Where the order's life cannot be walked as one stretch — it waited past the cap, or with the +/// position it outruns the long-position threshold — the window opens at the fill as before, and +/// nothing before it is owed. +#[test] +fn the_model_window_opens_at_the_fill_when_the_orders_life_is_not_one_stretch() { + let buy = 100 * MINUTE_MS; + let close = buy + 20_000; + let mut waited = deal_at(buy, close); + waited.buy_set_ms = Some(buy - ORDER_WAIT_CAP_MS - 1); + let window = model_window(&waited, MINUTE_MS, 60 * MINUTE_MS).expect("window"); + assert_eq!(window.open_ms, buy, "past the cap"); + + let mut long = deal_at(buy, buy + 59 * MINUTE_MS); + long.buy_set_ms = Some(buy - 2 * MINUTE_MS); + let window = model_window(&long, MINUTE_MS, 60 * MINUTE_MS).expect("window"); + assert_eq!( + window.open_ms, buy, + "creation to close outruns the threshold" + ); + assert_eq!( + window.long_position_ms, + 60 * MINUTE_MS, + "the caller's threshold" + ); + assert_eq!(required_spans(&window).spans()[0].0, buy - RUN_UP_MS); + + // A creation stamp after the fill is no order's, and is not taken. + let mut odd = deal_at(buy, close); + odd.buy_set_ms = Some(buy + 1); + assert_eq!(odd.order_open_ms(), None); assert_eq!( - required_spans(&deal_at(buy, close), &bare).spans(), - &[(buy, close)] + model_window(&odd, MINUTE_MS, 60 * MINUTE_MS) + .expect("window") + .open_ms, + buy ); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index 1ff1d27c..e04d71e4 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -18,9 +18,9 @@ use gpui::*; use super::super::super::AnalyticsView; use super::state::{RowAddress, TapeStatus}; use crate::Backend; -use moon_core::db::tuner::ticks::Deal; +use moon_core::db::tuner::ticks::{Deal, model_window}; use moon_core::market::MarketDataSource; -use moon_core::market::trade_replay::{model_margin_ms, replay_window_ms}; +use moon_core::market::trade_replay::{long_position_ms, model_margin_ms}; pub(crate) mod autoload; pub(in crate::analytics::tuner) mod job; @@ -89,16 +89,17 @@ impl FetchResolver { address } - /// The request for one addressed deal — the same addressing a trade window resolves: the - /// live source for the exchange identity, the core's contract terms for how the prints are - /// valued. `None` when the deal's stamps describe no window, or the core went away between - /// the address and now. + /// The request for one addressed deal — the model's window (`model_window`: from the entry + /// order's creation where the report stamps it) and the same addressing a trade window + /// resolves: the live source for the exchange identity, the core's contract terms for how + /// the prints are valued. `None` when the deal's stamps describe no window, or the core went + /// away between the address and now. pub(in crate::analytics::tuner) fn queued_row( &self, deal: Deal, address: Arc, ) -> Option { - let window = replay_window_ms(deal.buy_ms, deal.close_ms, model_margin_ms())?; + let window = model_window(&deal, model_margin_ms(), long_position_ms())?; let replay_address = self.source.replay_address(address.core_uid).ok()?; let terms = self .source diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs index 8994b36f..7cc8924d 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs @@ -37,10 +37,10 @@ use gpui::App; use super::job; use super::{FetchResolver, strategy_field_defaults}; use crate::Backend; -use moon_core::db::tuner::ticks::Deal; +use moon_core::db::tuner::ticks::{Deal, model_window}; use moon_core::market::trade_replay::venue_caps::trade_route; use moon_core::market::trade_replay::worker::inside_retention; -use moon_core::market::trade_replay::{model_margin_ms, replay_window_ms}; +use moon_core::market::trade_replay::{long_position_ms, model_margin_ms}; /// How far back the autoload looks, whatever the venue documents: the longest retention a /// route names is 90 days, and a month of rows is already thousands of walks. @@ -229,7 +229,7 @@ fn run_pass( for deal in deals { // Stamps that describe no window are the row's own fault, final: not a core that is // still coming, so never retried. - if replay_window_ms(deal.buy_ms, deal.close_ms, model_margin_ms()).is_none() { + if model_window(&deal, model_margin_ms(), long_position_ms()).is_none() { degenerate += 1; continue; } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs index 70317eb6..dca3cc55 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -424,18 +424,19 @@ pub(super) fn pick_dispatchable<'a>( .or_else(|| rows.iter().rposition(free)) } -/// What the cluster rule reads of a row: its market on its exchange, and its window. +/// What the cluster rule reads of a row: its market on its exchange, and its window — opened +/// where the row's own window opens (`model_window`: the entry order's creation, else the buy). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct ClusterKey<'a> { pub(super) exchange_key: &'a str, pub(super) market: &'a str, - pub(super) buy_ms: i64, + pub(super) open_ms: i64, pub(super) close_ms: i64, pub(super) margin_ms: i64, } /// The rows that go out with the seed in one request: every pending row of the seed's market -/// whose margined window overlaps the cluster's hull, taken while the hull's first entry and +/// whose margined window overlaps the cluster's hull, taken while the hull's first open and /// last exit stay within `long_position_ms` — the seed's own threshold, captured when its window /// was built (`ReplayWindow::long_position_ms`), past which the worker walks a stretch as its /// two ends. Grows until nothing more joins — a row that joins can bridge to the next one. @@ -454,7 +455,7 @@ pub(super) fn pick_cluster( ) -> Vec { let anchor = rows[seed]; let mut taken = vec![seed]; - let (mut first_buy, mut last_close) = (anchor.buy_ms, anchor.close_ms); + let (mut first_open, mut last_close) = (anchor.open_ms, anchor.close_ms); loop { let mut grew = false; for (index, row) in rows.iter().enumerate() { @@ -464,15 +465,15 @@ pub(super) fn pick_cluster( { continue; } - let overlaps = row.buy_ms.saturating_sub(row.margin_ms) + let overlaps = row.open_ms.saturating_sub(row.margin_ms) <= last_close.saturating_add(anchor.margin_ms) && row.close_ms.saturating_add(row.margin_ms) - >= first_buy.saturating_sub(anchor.margin_ms); - let hull_from = first_buy.min(row.buy_ms); + >= first_open.saturating_sub(anchor.margin_ms); + let hull_from = first_open.min(row.open_ms); let hull_to = last_close.max(row.close_ms); if overlaps && hull_to.saturating_sub(hull_from) <= long_position_ms { taken.push(index); - first_buy = hull_from; + first_open = hull_from; last_close = hull_to; grew = true; } @@ -546,7 +547,7 @@ fn run(job: &'static Job) { .map(|row| ClusterKey { exchange_key: &row.address.exchange_key, market: &row.address.market, - buy_ms: row.deal.buy_ms, + open_ms: row.window.open_ms, close_ms: row.deal.close_ms, margin_ms: row.window.margin_ms, }) @@ -636,20 +637,20 @@ fn serve_cluster( ) { let uids: Vec = rows.iter().map(|r| r.deal.report_uid).collect(); let first = &rows[0]; - // The hull: the first entry to the last exit, with the seed's margin — what - // `pick_cluster` kept within the long-position threshold, so the worker walks it as one - // stretch. - let first_buy = rows + // The hull: the first window's open (an entry order's creation, else a buy) to the last + // exit, with the seed's margin — what `pick_cluster` kept within the long-position + // threshold, so the worker walks it as one stretch. + let first_open = rows .iter() - .map(|r| r.deal.buy_ms) + .map(|r| r.window.open_ms) .min() - .unwrap_or(first.deal.buy_ms); + .unwrap_or(first.window.open_ms); let last_close = rows .iter() .map(|r| r.deal.close_ms) .max() .unwrap_or(first.deal.close_ms); - let window = replay_window_ms(first_buy, last_close, first.window.margin_ms) + let window = replay_window_ms(first_open, last_close, first.window.margin_ms) .map(|hull| ReplayWindow { // The seed's threshold, not a fresh read: the hull was clustered by it, and the // walk and the post-walk check must split it the same way. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs index f6ffb9ee..8b6a4633 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs @@ -139,10 +139,10 @@ fn a_cluster_takes_the_overlapping_rows_of_one_market_within_a_long_position() { const SEC: i64 = 1_000; /// The threshold as this test hands it in: the default five minutes. const LONG_POSITION_MS: i64 = 5 * 60 * SEC; - let key = |exchange_key, market, buy_ms, close_ms| ClusterKey { + let key = |exchange_key, market, open_ms, close_ms| ClusterKey { exchange_key, market, - buy_ms, + open_ms, close_ms, margin_ms: 30 * SEC, }; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 29fe00e3..5c5992b1 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -27,8 +27,8 @@ use crate::analytics::refresh::{CatchUpOutcome, report_result_is_stale}; use moon_core::db::ReadFail; use moon_core::db::order_traces::{TraceEntry, read_many}; use moon_core::db::tuner::ticks::{ - Deal, DealsRead, EntryParams, entry_model_for, infer_tick, params, prepare_deal, - required_spans, verify, + Deal, DealsRead, EntryParams, OwnLines, entry_model_for, infer_tick, model_window, params, + prepare_deal, required_spans, verify, }; use moon_core::db::tuner::{VarStats, Variant, strategy_current_values, strategy_values_at}; use moon_core::feed::report_traces::ArchivedLineKind; @@ -36,7 +36,7 @@ use moon_core::feed::types::Tick; use moon_core::market::trade_replay::venue_caps::trade_route; use moon_core::market::trade_replay::worker::inside_retention; use moon_core::market::trade_replay::{ - Coverage, TickQuery, TickStatus, model_margin_ms, query_held, replay_window_ms, + Coverage, ReplayWindow, TickQuery, TickStatus, long_position_ms, model_margin_ms, query_held, }; /// How long a held query waits for the worker's answer. The coordinator answers held queries @@ -379,18 +379,18 @@ fn held_tapes( let asked: Vec<( i64, mpsc::Receiver, - Coverage, + ReplayWindow, )> = targets .iter() .filter_map(|(deal, address)| { - let (rx, spans) = ask_held(address, deal, long_position_ms)?; - Some((deal.report_uid, rx, spans)) + let (rx, window) = ask_held(address, deal, long_position_ms)?; + Some((deal.report_uid, rx, window)) }) .collect(); let asked_n = asked.len(); let mut out = HashMap::with_capacity(asked_n); let mut unanswered = 0usize; - for (uid, rx, spans) in asked { + for (uid, rx, window) in asked { if moon_core::db::current_is_cancelled() { break; } @@ -399,7 +399,7 @@ fn held_tapes( unanswered += 1; continue; }; - out.insert(uid, (answer.ticks, answer.covered, spans)); + out.insert(uid, (answer.ticks, answer.covered, window)); } if unanswered > 0 { log::info!( @@ -411,28 +411,27 @@ fn held_tapes( out } -/// One held query sent, with the spans it asked for; the answer arrives on the receiver. -/// `long_position_ms` is the caller's — a queued row's own window's, or one read for a whole -/// table — so the split is the one every other stage of that row used. +/// One held query sent, with the window whose spans it asked for; the answer arrives on the +/// receiver. The window is the model's (`model_window`: from the entry order's creation where +/// the report stamps it). `long_position_ms` is the caller's — a queued row's own window's, or +/// one read for a whole table — so the split is the one every other stage of that row used. fn ask_held( address: &RowAddress, deal: &Deal, long_position_ms: i64, ) -> Option<( mpsc::Receiver, - Coverage, + ReplayWindow, )> { - let mut window = replay_window_ms(deal.buy_ms, deal.close_ms, model_margin_ms())?; - window.long_position_ms = long_position_ms; - let spans = window.focus_spans(); + let window = model_window(deal, model_margin_ms(), long_position_ms)?; let (reply, rx) = mpsc::channel(); query_held(TickQuery { exchange_key: address.exchange_key.clone(), market: address.market.clone(), - spans: spans.clone(), + spans: window.focus_spans(), reply, }); - Some((rx, spans)) + Some((rx, window)) } /// The grid's "now" column: every selected strategy's current value per field, folded to @@ -461,12 +460,14 @@ fn now_values(targets: &[(i64, Option)], keys: &[String]) -> HashMap>, pub(super) exit_points: Option>, + pub(super) answered: bool, } impl ArchivedLines { @@ -486,6 +487,7 @@ impl ArchivedLines { Self { entry_points, exit_points, + answered: true, } } } @@ -511,8 +513,8 @@ fn archived_lines(rows: &[DealRow]) -> HashMap { out } -/// The held prints of one deal's window, their coverage, and the spans that were asked for. -type HeldTape = (Vec, Coverage, Coverage); +/// The held prints of one deal's window, their coverage, and the window that was asked for. +type HeldTape = (Vec, Coverage, ReplayWindow); /// The held prints of one deal's window, through the worker. `None` when the worker did not /// answer in time. @@ -521,9 +523,9 @@ pub(super) fn held_tape( deal: &Deal, long_position_ms: i64, ) -> Option { - let (rx, spans) = ask_held(address, deal, long_position_ms)?; + let (rx, window) = ask_held(address, deal, long_position_ms)?; let answer = rx.recv_timeout(HELD_ANSWER_WAIT).ok()?; - Some((answer.ticks, answer.covered, spans)) + Some((answer.ticks, answer.covered, window)) } /// Why a fetch of a row the terminal holds no tape for could only come back refused, said at @@ -536,7 +538,7 @@ fn unservable_status(address: &RowAddress, deal: &Deal, now_ms: i64) -> Option Deal { step_lag_ms: 0.0, stop_anchor: None, own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index c81d0cbe..9abab6c9 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -81,8 +81,9 @@ impl DealRow { /// Take everything a replay learned about this row from its answer: the tape's word, the /// verdict, the model inputs derived for the deal (the price step, the archived pre-spike - /// ask and take, the core's step lag, what the fact proves about the stop, the entry the - /// trade ran with), the prints, the entry line and the held coverage. + /// ask and take, where the entry order was placed, the core's step lag, what the fact proves + /// about the stop, the entry the trade ran with), the prints, the entry line and the held + /// coverage. /// Every fold of a replay answer goes through here: the variants replay the STORED row /// (`prepared_deals`), so a take lifted to the archive's pre-spike ask in the verdict but /// read off the tape in the variants puts the two on different levels, and a row folded @@ -94,6 +95,7 @@ impl DealRow { self.deal.tick = answer.deal.tick; self.deal.pre_spike_ask = answer.deal.pre_spike_ask; self.deal.archived_take = answer.deal.archived_take; + self.deal.entry_placed = answer.deal.entry_placed; self.deal.step_lag_ms = answer.deal.step_lag_ms; self.deal.stop_anchor = answer.deal.stop_anchor; self.deal.own_entry = answer.deal.own_entry; diff --git a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs index f4e02102..c9b1c287 100644 --- a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs +++ b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs @@ -34,11 +34,10 @@ use crate::design; use moon_core::db::ReportAxis; use moon_core::db::ReportStamp; use moon_core::db::tape_owners::{TapeOwner, read_tape_owners}; +use moon_core::db::tuner::ticks::{ORDER_WAIT_CAP_MS, model_window_at, order_open_at}; use moon_core::market::MarketDataSource; use moon_core::market::trade_replay::trade_cache::{self, Inventory, KeepMap, TrimReport}; -use moon_core::market::trade_replay::{ - Coverage, long_position_ms, model_margin_ms, replay_window_ms, worker, -}; +use moon_core::market::trade_replay::{Coverage, long_position_ms, model_margin_ms, worker}; use moon_core::symbol::Exchange; /// What one pass found — the preview's numbers, or the apply's. @@ -88,12 +87,14 @@ impl Margins { } } - /// How far, in seconds, a row's claim can reach past its own stamps — the margin, rounded - /// up. A row that opened this much after the file's last print, or closed this much before - /// its first, still claims prints inside the file, so the replica is read that much wider - /// than the file's range. + /// How far, in seconds, a row's claim can reach past its own stamps — the margin, and before + /// the entry the entry order's life on top of it (`ORDER_WAIT_CAP_MS`), rounded up. A row + /// that opened this much after the file's last print still claims prints inside the file, so + /// the replica is read that much wider than the file's range. The order's life reaches only + /// before the entry: on the other bound the reach is wider than any claim, and the few rows + /// it adds claim nothing. fn reach_s(self) -> i64 { - self.model_ms.div_euclid(1_000) + 1 + (self.model_ms + ORDER_WAIT_CAP_MS).div_euclid(1_000) + 1 } } @@ -231,12 +232,19 @@ fn build_keep( if *by_name_only { preview.by_name += 1; } - for (open_ms, close_ms) in stamps(axis, owner) { - let Some(mut window) = replay_window_ms(open_ms, close_ms, margins.model_ms) else { + for stamp in stamps(axis, owner) { + // The tuner's own window (`model_window_at`): from the entry order's creation where + // the row carries it, so the lead the tuner fetched for the order's life is claimed + // with the trade — split by the pass's own threshold, not one read a moment later. + let Some(window) = model_window_at( + order_open_at(stamp.buy_ms, stamp.buy_set_ms), + stamp.buy_ms, + stamp.close_ms, + margins.model_ms, + margins.long_position_ms, + ) else { continue; }; - // The pass's own snapshot, not what `replay_window_ms` read a moment later. - window.long_position_ms = margins.long_position_ms; let focus = window.focus_spans(); for key in keys.iter() { let coverage = keep.entry(key.clone()).or_insert_with(Coverage::none); @@ -253,17 +261,42 @@ fn build_keep( /// for the catalog. type Addresses = HashMap<(u64, String), (Vec<(String, String)>, bool)>; -/// The row's entry and exit on the file's clock: through the core's measured offset, the way -/// the trade window and the close-time capture stamp their requests — and, when both stamps -/// are milliseconds, the raw values too, the way the tuner's fetch stamps its. Two claims where -/// the clocks disagree, so neither path's tape is cut as the other's excess. -fn stamps(axis: &ReportAxis, owner: &TapeOwner) -> Vec<(i64, i64)> { - let lifted = axis.stamp_pair_to_utc_ms(owner.buy, owner.close, owner.core_uid); +/// One claim's stamps on one clock: the entry, the exit, and the entry order's creation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ClaimStamps { + buy_ms: i64, + close_ms: i64, + buy_set_ms: Option, +} + +/// The row's stamps on the file's clock: through the core's measured offset, the way the trade +/// window and the close-time capture stamp their requests — and, when both stamps are +/// milliseconds, the raw values too, the way the tuner's fetch stamps its. Two claims where the +/// clocks disagree, so neither path's tape is cut as the other's excess. The order's creation is +/// a millisecond stamp on the entry's clock and moves with it. +fn stamps(axis: &ReportAxis, owner: &TapeOwner) -> Vec { + let (buy_ms, close_ms) = axis.stamp_pair_to_utc_ms(owner.buy, owner.close, owner.core_uid); + let raw_buy_ms = match owner.buy { + ReportStamp::Millis(buy) => Some(buy), + ReportStamp::Seconds(_) => None, + }; + let lifted = ClaimStamps { + buy_ms, + close_ms, + buy_set_ms: owner + .buy_set_ms + .zip(raw_buy_ms) + .map(|(set, raw)| set + (buy_ms - raw)), + }; let mut out = vec![lifted]; if let (ReportStamp::Millis(buy), ReportStamp::Millis(close)) = (owner.buy, owner.close) - && (buy, close) != lifted + && (buy, close) != (buy_ms, close_ms) { - out.push((buy, close)); + out.push(ClaimStamps { + buy_ms: buy, + close_ms: close, + buy_set_ms: owner.buy_set_ms, + }); } out } diff --git a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs index 82bbd48d..4b61f2aa 100644 --- a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs +++ b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs @@ -7,8 +7,8 @@ use std::collections::HashMap; use std::sync::Arc; use super::{ - Inventory, KeepMap, Margins, OWNER_SLACK_S, ReportAxis, ReportStamp, TapeOwner, build_keep, - read_tape_owners, stamps, trade_cache, + ClaimStamps, Inventory, KeepMap, Margins, OWNER_SLACK_S, ReportAxis, ReportStamp, TapeOwner, + build_keep, read_tape_owners, stamps, trade_cache, }; const MARGINS: Margins = Margins { @@ -24,6 +24,7 @@ fn owner(core_uid: u64, coin: &str, buy_ms: i64, close_ms: i64, kind: &str) -> T close: ReportStamp::Millis(close_ms), strategy_id: 42, sell_reason: "Sell Price".into(), + buy_set_ms: None, kind: kind.into(), } } @@ -97,6 +98,43 @@ fn overlapping_claims_are_one_stretch() { ); } +/// A row that carries its entry order's creation claims from there, as the tuner fetches it +/// (`model_window_at`) — for every kind the tuner runs on, a model or none; one whose creation +/// and position together outrun the long-position threshold claims from its fill, as before. +#[test] +fn a_row_with_its_orders_creation_claims_from_the_creation() { + let inv = inventory(&[("4:0", "ACEUSDT")]); + let mut hook = owner(1, "ACE", 1_000_000, 1_010_000, "MoonHook"); + hook.buy_set_ms = Some(1_000_000 - 120_000); + let (keep, _) = build_keep( + &inv, + &[&hook], + &ReportAxis::default(), + "es(), + MARGINS, + |_| Some(key("4:0", "ACEUSDT")), + ); + assert_eq!( + spans(&keep, &key("4:0", "ACEUSDT")), + vec![(1_000_000 - 120_000 - 60_000, 1_010_000 + 60_000)] + ); + let mut long = owner(1, "ACE", 1_000_000, 1_000_000 + 4 * 60_000, "MoonShot"); + long.buy_set_ms = Some(1_000_000 - 120_000); + let (keep, _) = build_keep( + &inv, + &[&long], + &ReportAxis::default(), + "es(), + MARGINS, + |_| Some(key("4:0", "ACEUSDT")), + ); + assert_eq!( + spans(&keep, &key("4:0", "ACEUSDT"))[0].0, + 1_000_000 - 60_000, + "creation to close outruns five minutes: the window opens at the fill" + ); +} + /// A position held longer than five minutes claims its two ends, not its middle. #[test] fn a_long_position_claims_its_two_ends() { @@ -189,10 +227,16 @@ fn a_row_off_the_file_is_unresolved() { /// the window and the capture use, and the raw one the tuner's fetch uses. #[test] fn stamps_claim_both_clocks_when_they_differ() { - let row = owner(1, "ACE", 1_000_000, 1_010_000, ""); + let mut row = owner(1, "ACE", 1_000_000, 1_010_000, ""); + row.buy_set_ms = Some(990_000); + let claim = |buy_ms, close_ms, buy_set_ms| ClaimStamps { + buy_ms, + close_ms, + buy_set_ms, + }; assert_eq!( stamps(&ReportAxis::default(), &row), - vec![(1_000_000, 1_010_000)] + vec![claim(1_000_000, 1_010_000, Some(990_000))] ); let axis = ReportAxis::from_measured( HashMap::from([( @@ -204,32 +248,40 @@ fn stamps_claim_both_clocks_when_they_differ() { )]), chrono_tz::UTC, ); - let lifted = axis.stamp_pair_to_utc_ms(row.buy, row.close, 1); - assert_ne!(lifted, (1_000_000, 1_010_000)); - assert_eq!(stamps(&axis, &row), vec![lifted, (1_000_000, 1_010_000)]); + let (buy, close) = axis.stamp_pair_to_utc_ms(row.buy, row.close, 1); + assert_ne!((buy, close), (1_000_000, 1_010_000)); + // The creation moves with the entry's clock. + assert_eq!( + stamps(&axis, &row), + vec![ + claim(buy, close, Some(990_000 + (buy - 1_000_000))), + claim(1_000_000, 1_010_000, Some(990_000)) + ] + ); let seconds = TapeOwner { buy: ReportStamp::Seconds(1_000), close: ReportStamp::Seconds(1_010), + buy_set_ms: None, ..row }; assert_eq!( stamps(&ReportAxis::default(), &seconds), - vec![(1_000_000, 1_010_000)] + vec![claim(1_000_000, 1_010_000, None)] ); } -/// The replica is read as far past the file's range as the wider margin reaches, rounded up -/// to whole seconds. +/// The replica is read as far past the file's range as a claim reaches — the margin and an entry +/// order's longest replayed wait — rounded up to whole seconds. #[test] -fn the_reach_is_the_wider_margin_in_whole_seconds() { - assert_eq!(MARGINS.reach_s(), 61); +fn the_reach_is_the_margin_and_the_orders_wait_in_whole_seconds() { + assert_eq!(MARGINS.reach_s(), 661); assert_eq!( Margins { model_ms: 7_200_000, long_position_ms: 60_000 } .reach_s(), - 7_201 + 7_801 ); } From 3adaf387a7ad4ad118b8cc11cc162bffa76cbe59 Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 12:29:34 +0200 Subject: [PATCH 21/51] fix(trade-replay): one tape margin with a 30 s floor, and a trade past the venue's retention served from the held tape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/config/storage.rs | 29 ++-- crates/moon-core/src/config/storage/tests.rs | 35 +++- crates/moon-core/src/db/tuner/ticks/mod.rs | 28 +-- .../src/db/tuner/ticks/tests/real_data.rs | 40 ++++- .../src/db/tuner/ticks/tests/required.rs | 28 ++- .../moon-core/src/market/trade_replay/mod.rs | 23 +-- .../src/market/trade_replay/settings.rs | 18 +- .../src/market/trade_replay/tests.rs | 9 +- .../src/market/trade_replay/worker.rs | 26 +-- .../src/market/trade_replay/worker/tests.rs | 159 ------------------ crates/moon-core/src/session/lifecycle.rs | 11 +- .../src/analytics/tuner/ticks/fetch.rs | 4 +- .../analytics/tuner/ticks/fetch/autoload.rs | 4 +- .../src/analytics/tuner/ticks/load.rs | 6 +- crates/moon-ui-gpui/src/settings/storage.rs | 15 +- .../src/settings/storage/trades_cleanup.rs | 14 +- .../settings/storage/trades_cleanup/tests.rs | 21 +-- locales/storage.yml | 6 +- 18 files changed, 201 insertions(+), 275 deletions(-) diff --git a/crates/moon-core/src/config/storage.rs b/crates/moon-core/src/config/storage.rs index 04615bdf..f48826e8 100644 --- a/crates/moon-core/src/config/storage.rs +++ b/crates/moon-core/src/config/storage.rs @@ -63,8 +63,8 @@ pub struct TradeReplayStoreCfg { pub max_mb: u32, /// Seconds of prints kept around a trade, per end: a short position gets this much before /// its entry and after its exit; a long one ([`Self::long_position_min`] or longer) gets - /// this much centred on each end, half before and half after, with bars between. It sizes what a trade window fetches, - /// what a close copies out of the core's ring, and what the file keeps. One of + /// this much on both sides of each end, with bars between. It sizes what a trade window + /// fetches, what a close copies out of the core's ring, and what the file keeps. One of /// [`TRADE_MARGIN_STEPS_S`]: a hand-edited value is snapped to the nearest step on load. pub margin_s: u32, /// Whether the terminal fetches, once the cores are up, the tape of every recent closed @@ -98,18 +98,21 @@ pub const LONG_POSITION_MIN_RANGE: std::ops::RangeInclusive = 1..=120; pub const DEFAULT_TRADES_MAX_MB: u32 = 256; /// The values [`TradeReplayStoreCfg::margin_s`] may take, ascending: the Storage tab steps -/// through this list rather than by a fixed amount, so the short end is fine-grained (5 s for a -/// scalp) and the long end coarse. The floor is 5 s — "the position alone" is gone: a window -/// with no prints outside the position has nothing to show around the entry. The ceiling is two -/// hours: the bar context after an exit is two hours at least, and prints past the bars would -/// have nowhere to draw. -pub const TRADE_MARGIN_STEPS_S: &[u32] = &[5, 10, 30, 60, 180, 300, 600, 900, 1800, 3600, 7200]; +/// through this list rather than by a fixed amount, so the short end is fine-grained and the +/// long end coarse. The floor is 30 s — the tuner's run-up and tail +/// (`trade_replay::MODEL_PAD_MS`): one setting sizes the chart's window, the close-time capture, +/// the tuner's fetch and the cleanup alike, and none of them pads it behind the tab's back (the +/// developer's call, 2026-09-23; the steps started at 5 s before that, and the tuner lifted +/// them to a minute on its own). 65 s is a step so the default survives the snap; it is not +/// the floor. The ceiling is two hours: the bar context after an exit is two hours at least, +/// and prints past the bars would have nowhere to draw. +pub const TRADE_MARGIN_STEPS_S: &[u32] = &[30, 60, 65, 180, 300, 600, 900, 1800, 3600, 7200]; -/// Default seconds of prints around a trade, per end — 5 s (the developer's call, 2026-09-21; -/// 15 minutes before that). The tuner's model window and the close-time capture both pad this -/// to at least the model's own run-up and tail (`trade_replay::model_margin_ms`), so the short -/// default shapes the chart's windows, not what the tuner is served. -pub const DEFAULT_TRADE_MARGIN_S: u32 = 5; +/// Default seconds of prints around a trade, per end. 65 s (the user's call, 2026-09-26; +/// 30 s from 2026-09-23, 5 s from 2026-09-21, 15 minutes before that). Not the floor of +/// [`TRADE_MARGIN_STEPS_S`]: 30 s stays the tuner's pad and a step, so a file that already +/// stores 30 keeps 30. A file with no margin key at all takes this default. +pub const DEFAULT_TRADE_MARGIN_S: u32 = 65; /// Ceiling on [`TradeReplayStoreCfg::margin_s`] — the last of [`TRADE_MARGIN_STEPS_S`]. pub const MAX_TRADE_MARGIN_S: u32 = 7200; diff --git a/crates/moon-core/src/config/storage/tests.rs b/crates/moon-core/src/config/storage/tests.rs index 276c9472..2448ecfb 100644 --- a/crates/moon-core/src/config/storage/tests.rs +++ b/crates/moon-core/src/config/storage/tests.rs @@ -111,6 +111,23 @@ margin_s = 36000 assert_eq!(sanitize(back).trade_replay.margin_s, 30); } +/// A file written while the steps started at 5 s — the default of 2026-09-21 is on disk in +/// every terminal that never touched the setting — loads at the new floor, 30 s: the tuner's +/// run-up and tail, which the setting is no longer padded to behind the tab's back. +#[test] +fn a_margin_under_the_floor_loads_at_the_floor() { + for old in [5, 10] { + let cfg: StorageCfg = toml::from_str(&format!("[trade_replay]\nmargin_s = {old}\n")) + .expect("old file parses"); + assert_eq!(sanitize(cfg).trade_replay.margin_s, 30, "margin_s = {old}"); + } + assert_eq!( + i64::from(TRADE_MARGIN_STEPS_S[0]) * 1_000, + crate::market::trade_replay::MODEL_PAD_MS, + "the floor is the tuner's pad" + ); +} + /// The step list is what the snap and the stepper agree on: every step snaps to itself, the /// ends absorb what lies beyond them, and the default and the ceiling are both members. #[test] @@ -123,12 +140,13 @@ fn snap_and_step_walk_the_step_list() { TRADE_MARGIN_STEPS_S.last().copied(), Some(MAX_TRADE_MARGIN_S) ); - assert_eq!(snap_trade_margin_s(0), 5); - assert_eq!(snap_trade_margin_s(7), 5, "nearer to 5 than to 10"); - assert_eq!(snap_trade_margin_s(8), 10); - assert_eq!(snap_trade_margin_s(19), 10); - assert_eq!(snap_trade_margin_s(20), 10, "tie goes to the lower step"); - assert_eq!(snap_trade_margin_s(21), 30); + assert_eq!(snap_trade_margin_s(0), 30); + assert_eq!(snap_trade_margin_s(44), 30); + assert_eq!(snap_trade_margin_s(45), 30, "tie goes to the lower step"); + assert_eq!(snap_trade_margin_s(46), 60); + assert_eq!(snap_trade_margin_s(65), 65); + assert_eq!(snap_trade_margin_s(62), 60); + assert_eq!(snap_trade_margin_s(63), 65); assert_eq!(snap_trade_margin_s(u32::MAX), MAX_TRADE_MARGIN_S); assert_eq!(step_trade_margin_s(900, 1), 1800); @@ -139,7 +157,10 @@ fn snap_and_step_walk_the_step_list() { 7200, "the top absorbs the rest" ); - assert_eq!(step_trade_margin_s(5, -1), 5, "so does the bottom"); + assert_eq!(step_trade_margin_s(30, -1), 30, "so does the bottom"); + assert_eq!(step_trade_margin_s(60, 1), 65); + assert_eq!(step_trade_margin_s(65, 1), 180); + assert_eq!(step_trade_margin_s(65, -1), 60); assert_eq!( step_trade_margin_s(2700, 1), 3600, diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index fbba152d..ce393537 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -289,7 +289,7 @@ pub fn model_window(deal: &Deal, margin_ms: i64, long_position_ms: i64) -> Optio /// The replay window of a trade as the model needs it: from the entry order's creation /// ([`order_open_at`]) through the close, one stretch. Where that stretch would be walked as its /// two ends — the order's life plus the position outrun `long_position_ms` — the window opens at -/// the fill as before: an entry end centred on the creation would leave the fill itself between +/// the fill as before: an entry end around the creation would leave the fill itself between /// the ends, where nothing is fetched. The tape cleanup claims by this same rule /// (`trades_cleanup`), so what the tuner fetched is what it keeps. /// @@ -297,7 +297,7 @@ pub fn model_window(deal: &Deal, margin_ms: i64, long_position_ms: i64) -> Optio /// order_open_ms: The order's creation where a replay may start there ([`order_open_at`]). /// buy_ms: The fill of the entry. /// close_ms: The close. -/// margin_ms: The model's margin (`trade_replay::model_margin_ms`). +/// margin_ms: The margin setting (`trade_replay::margin_ms`). /// long_position_ms: The threshold the window is split by — the caller's, so every stage /// of one row splits it the same way. /// @@ -402,14 +402,16 @@ pub const TAIL_MS: i64 = crate::market::trade_replay::MODEL_PAD_MS; /// The part of a deal's window the model cannot do without: the run-up before the window's open /// — the entry order's creation where [`model_window`] opened there, else the buy — through the -/// tail after the close, clipped to what the window asks for at all (a long position asks only -/// around its two ends; a model's window is built with a margin of at least the pads, see -/// `trade_replay::model_margin_ms`). Read off the window rather than the deal: the worker walks -/// the pads around the window's own open as part of the trade, and a requirement reaching past -/// them would name prints nobody was sure to fetch. The rest of the window — the trail beyond -/// the tail, the lead beyond the run-up — is served as far as the tape goes: a venue's page -/// budget runs out on the trail of a pumped coin long before the margin, and a variant that -/// outlives the tape is marked open at the window's end. +/// tail after the close, clipped to what the window asks for at all — a long position asks only +/// around its two ends, and of each end the model is owed the pads, not the margin. The margin +/// on both sides of a long position's end is the window's context: owed in full, a wider setting +/// turned a trade the model already had into a missing one, and one past the venue's retention +/// could never be covered again (2026-09-23). Read off the window rather than the deal: the +/// worker walks the pads around the window's own open as part of the trade, and a requirement +/// reaching past them would name prints nobody was sure to fetch. The rest of the window — the +/// trail beyond the tail, the lead beyond the run-up — is served as far as the tape goes: a +/// venue's page budget runs out on the trail of a pumped coin long before the margin, and a +/// variant that outlives the tape is marked open at the window's end. /// /// Args: /// window: The deal's window, as asked from the worker. @@ -417,11 +419,15 @@ pub const TAIL_MS: i64 = crate::market::trade_replay::MODEL_PAD_MS; /// Returns: /// The spans the held coverage must include for the deal to count as covered. pub fn required_spans(window: &ReplayWindow) -> Coverage { + let pads = ReplayWindow { + margin_ms: window.margin_ms.min(RUN_UP_MS.max(TAIL_MS)), + ..*window + }; Coverage::one(( window.open_ms.saturating_sub(RUN_UP_MS), window.close_ms.saturating_add(TAIL_MS), )) - .clip(&window.focus_spans()) + .clip(&pads.focus_spans()) } /// Run one trade through the entry and the exit model. diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index feaccbb9..f2ee8801 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -73,7 +73,9 @@ fn held_ticks(exchange_key: &str, market: &str, spans: &Coverage) -> (Vec, /// One deal as the verdict saw it, for an analysis outside the probe: a JSON line in /// `/deals.jsonl` (the row, the strategy's raw values, the held walk's points, the archived -/// Exit line) and its prints as `t,price,qty,side` in `/ticks/.csv`. +/// Entry and Exit lines, the entry order's creation, placement and saved corridor, the MoonShot +/// bounds) and its prints as `t,price,qty,side` in `/ticks/.csv`. +#[allow(clippy::too_many_arguments)] fn dump_deal( dir: &str, deal: &Deal, @@ -82,6 +84,7 @@ fn dump_deal( held: &super::super::line::LineWalk, exit_points: Option<&[(i64, f64)]>, entry_points: Option<&[(i64, f64)]>, + entry: &EntryParams, ) { use std::io::Write; let dir = PathBuf::from(dir); @@ -103,6 +106,26 @@ fn dump_deal( "held_points": held.points.iter().map(|p| (p.t_ms, p.price)).collect::>(), "archive": exit_points, "entry": entry_points, + "buy_set_ms": deal.buy_set_ms, + "order_open_ms": deal.order_open_ms(), + "entry_placed": deal.entry_placed, + "corridor": deal.corridor, + "mshot": match entry { + EntryParams::MoonShot(p) => { + let (near, far) = p.bounds_pct(&deal.deltas); + serde_json::json!({ + "near": near, + "far": far, + "use_price": format!("{:?}", p.use_price), + "raise_wait_s": p.raise_wait_s, + "replace_delay_s": p.replace_delay_s, + "minus_satoshi": p.minus_satoshi, + "fast_algo": p.fast_algo, + "latency_ms": p.latency_ms, + }) + } + _ => serde_json::Value::Null, + }, }); if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) @@ -254,7 +277,7 @@ fn real_data_reproduction() { .expect("pairs") .flatten() .collect(); - let margin_ms = crate::market::trade_replay::model_margin_ms(); + let margin_ms = crate::market::trade_replay::margin_ms(); let venue_of_core = core_venues(); eprintln!("core venues (from the identity lines of the logs): {venue_of_core:?}"); @@ -324,6 +347,18 @@ fn real_data_reproduction() { if ticks.is_empty() || !covered.covers(&required_spans(&window)) { continue; } + // What the verdict comes to on a shorter tape: `MOON_TICKS_CLIP_MS=5000` keeps only that + // much before the window's open and past the close, as a margin setting that low would. + if let Some(clip_ms) = std::env::var("MOON_TICKS_CLIP_MS") + .ok() + .and_then(|v| v.parse::().ok()) + { + let (from_ms, to_ms) = (window.open_ms - clip_ms, window.close_ms + clip_ms); + ticks.retain(|t| (from_ms..=to_ms).contains(&(t.time_ms as i64))); + if ticks.is_empty() { + continue; + } + } with_tape += 1; deal.tick = infer_tick(&ticks); let (entry_line, exit_points, answered) = archived_lines(&deal); @@ -403,6 +438,7 @@ fn real_data_reproduction() { &held, exit_points.as_deref(), entry_line.as_deref(), + &entry, ); } eprintln!( diff --git a/crates/moon-core/src/db/tuner/ticks/tests/required.rs b/crates/moon-core/src/db/tuner/ticks/tests/required.rs index 389337c3..d0943215 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/required.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/required.rs @@ -32,6 +32,29 @@ fn short_deal_requires_the_run_up_through_the_tail_only() { assert!(!ends_at_close.covers(&required), "neither is the tail"); } +/// A long position owes the model its run-up and tail at each end — the pads — whatever the +/// margin: the margin is what the window asks for, and a wider setting must not turn a trade the +/// model already has into a missing one. A long trade held at 30 s a side went missing the moment +/// the setting moved to 3 min, and one past the venue's retention could never be covered again +/// (2026-09-23). +#[test] +fn a_long_position_owes_only_the_pads_whatever_the_margin() { + let buy = 100 * MINUTE_MS; + let close = buy + 60 * MINUTE_MS; + for margin in [RUN_UP_MS, 3 * MINUTE_MS, 10 * MINUTE_MS] { + let window = replay_window_ms(buy, close, margin).expect("window"); + assert!(window.focus_spans().is_split(), "margin {margin}"); + assert_eq!( + required_spans(&window).spans(), + &[ + (buy - RUN_UP_MS, buy + RUN_UP_MS), + (close - TAIL_MS, close + TAIL_MS) + ], + "margin {margin}" + ); + } +} + /// A long position's window asks only around its two ends, so the requirement is clipped to /// them: the unwalked hours in the middle are not owed. A zero margin asks for the position /// alone, and the requirement shrinks to it. @@ -44,12 +67,11 @@ fn long_position_and_zero_margin_require_only_what_the_window_asks() { let spans = window.focus_spans(); assert!(spans.is_split()); let required = required_spans(&window); - let half = margin / 2; assert_eq!( required.spans(), &[ - (buy - RUN_UP_MS, buy + half), - (close - half, close + TAIL_MS) + (buy - RUN_UP_MS, buy + RUN_UP_MS), + (close - TAIL_MS, close + TAIL_MS) ] ); assert!(spans.covers(&required)); diff --git a/crates/moon-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index b6c22603..d98632c5 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -43,8 +43,8 @@ use crate::market::{CandleReadParams, ChartHistoryBuffers, ChartHistoryRead}; use crate::venue::{Brand, Venue}; pub use coverage::Coverage; pub use settings::{ - cleanup_at_startup, long_position_ms, margin_ms, model_margin_ms, set_cleanup_at_startup, - set_long_position_min, set_margin_s, set_tape_autoload, tape_autoload, + cleanup_at_startup, long_position_ms, margin_ms, set_cleanup_at_startup, set_long_position_min, + set_margin_s, set_tape_autoload, tape_autoload, }; pub use worker::{TickAnswer, TickQuery, query_held}; @@ -83,7 +83,7 @@ const CONTEXT_FRACTION: f64 = 0.5; // A position held longer than `[trade_replay] long_position_min` ([`long_position_ms`]) asks // for ticks only around its entry and its exit ([`ReplayWindow::focus_spans`]), each end -// getting the window's margin centred on it; the middle stays bars. +// getting the window's margin on both sides of it; the middle stays bars. // // A meaning bound, not a resource one: the page budget already caps what a walk can fetch, but // on a multi-hour position it burned out ~40 minutes after the entry and the exit came back as @@ -326,11 +326,10 @@ pub struct ReplayWindow { /// Millisecond-exact when the core supplied a millisecond column, whole seconds otherwise. pub close_ms: i64, /// How many milliseconds of prints are asked for around the position, per end — the - /// `[trade_replay] margin_s` setting at the moment the window was built (floored for a - /// model's window, see `model_margin_ms`). A short position - /// gets this much before the entry and after the exit ([`Self::focus`]); a long one gets it - /// centred on each end ([`Self::focus_spans`]). Zero is the position alone — a value the - /// setting no longer offers, but one a hand-built window may still carry. + /// `[trade_replay] margin_s` setting at the moment the window was built ([`margin_ms`]). + /// A short position gets this much before the entry and after the exit ([`Self::focus`]); + /// a long one gets it on both sides of each end ([`Self::focus_spans`]). Zero is the position + /// alone — a value the setting no longer offers, but one a hand-built window may still carry. pub margin_ms: i64, /// How long a position must be held to be walked as its two ends — the `[trade_replay] /// long_position_min` setting at the moment the window was built ([`long_position_ms`]), @@ -391,9 +390,11 @@ impl ReplayWindow { (left, right) } /// The stretches actually requested as ticks: the whole [`Self::focus`] on a position held up - /// to [`Self::long_position_ms`]; on a longer one, [`Self::margin_ms`] centred on the entry - /// and on the exit — half before each end, half after — two spans with the middle left to - /// bars. + /// to [`Self::long_position_ms`]; on a longer one, [`Self::margin_ms`] on both sides of the + /// entry and of the exit — two spans with the middle left to bars. Both sides, not half the + /// margin each: the stretch before the entry and past the exit is what the tuner's run-up + /// and exit horizon are, and a long position must get the same margin there as a short one + /// (the developer's call, 2026-09-23; the margin was centred on each end before). /// /// Returns: /// One or two spans, each clamped into `[Self::from_ms, Self::to_ms]`. The two of a long diff --git a/crates/moon-core/src/market/trade_replay/settings.rs b/crates/moon-core/src/market/trade_replay/settings.rs index f2da5fb2..cca6f15e 100644 --- a/crates/moon-core/src/market/trade_replay/settings.rs +++ b/crates/moon-core/src/market/trade_replay/settings.rs @@ -40,24 +40,16 @@ fn init() { }); } -/// The configured margin, in milliseconds — what every new chart [`super::ReplayWindow`] is -/// built with. A model's window and the close-time capture take [`model_margin_ms`] instead. +/// The configured margin, in milliseconds — what every new [`super::ReplayWindow`] is built +/// with: a chart's, a tuner's, the close-time capture's, and the cleanup's claims. Its floor is +/// the model's pad ([`super::MODEL_PAD_MS`], `config::storage::TRADE_MARGIN_STEPS_S`), so every +/// position carries the whole run-up and tail — a long one gets the margin on both sides of each +/// end ([`super::ReplayWindow::focus_spans`]). pub fn margin_ms() -> i64 { init(); i64::from(MARGIN_S.load(Ordering::Relaxed)) * 1_000 } -/// The margin a MODEL's window is built with: the chart's margin, but never less than TWICE -/// the model's own pad ([`super::MODEL_PAD_MS`]). The plan's trade tiles and the model's -/// required span are both clipped to the window's focus, so a chart margin under the pad — -/// 10 s is a valid setting — would otherwise leave the model without its -/// run-up and its tail and never say so. Twice, because a long position's focus centres the -/// margin on each end ([`super::ReplayWindow::focus_spans`]): half of it lies outside the -/// position, and that half must still be a whole pad. -pub fn model_margin_ms() -> i64 { - margin_ms().max(2 * super::MODEL_PAD_MS) -} - /// Move the live margin; the Storage tab writes `storage.toml` beside this. Windows already open /// keep the margin they were built with; the next one asks for the new stretch, and the tile /// store hands back what earlier windows already fetched of it. Snapped onto the step list like diff --git a/crates/moon-core/src/market/trade_replay/tests.rs b/crates/moon-core/src/market/trade_replay/tests.rs index 9ba47d64..c234d079 100644 --- a/crates/moon-core/src/market/trade_replay/tests.rs +++ b/crates/moon-core/src/market/trade_replay/tests.rs @@ -1086,12 +1086,14 @@ fn kline_tick_statuses_keep_the_same_chart_revision_while_ticks_change_it() { } /// A position held up to `long_position_ms()` keeps one focus; past it the focus is two -/// neighbourhoods — the margin centred on each end, half before and half after — clamped into -/// the window like the whole one. +/// neighbourhoods — the whole margin on both sides of each end, so the run-up before the entry +/// and the tail past the exit are the margin, as on a short position — clamped into the window +/// like the whole one. #[test] fn focus_spans_split_only_a_long_position() { // A margin well under the position's length, so the two ends of a long one stay apart: - // halves that reach each other fold into one stretch, which the end of this test pins. + // neighbourhoods that reach each other fold into one stretch, which the end of this test + // pins. let margin_ms: i64 = long_position_ms() / 5; let short = replay_window_ms(100_000_000, 100_000_000 + long_position_ms(), margin_ms).expect("window"); @@ -1101,7 +1103,6 @@ fn focus_spans_split_only_a_long_position() { let close_ms = open_ms + long_position_ms() + 1; let long = replay_window_ms(open_ms, close_ms, margin_ms).expect("window"); let spans = long.focus_spans(); - let half = margin_ms / 2; assert_eq!( spans.spans(), &[ diff --git a/crates/moon-core/src/market/trade_replay/worker.rs b/crates/moon-core/src/market/trade_replay/worker.rs index 45213a2e..635fc89b 100644 --- a/crates/moon-core/src/market/trade_replay/worker.rs +++ b/crates/moon-core/src/market/trade_replay/worker.rs @@ -193,7 +193,9 @@ pub(crate) struct TickStage { /// the stage asks nothing and serves what the tile store and its disk already hold for the /// focus (a capture from the core's archive, filed when the trade closed — or, for a tiles /// reader, filed by this stage itself out of the ring, see [`ReplayIntent::files_core`]), - /// or prints [`TickStatus::NoRoute`] as before when they hold nothing. + /// or prints [`TickStatus::NoRoute`] as before when they hold nothing. A route whose + /// retention the whole focus is past is served the same way, printing + /// [`TickStatus::OutOfRetention`] instead. route: Option, /// The ring key this stage's answer replaces on success. key: OutcomeKey, @@ -237,9 +239,8 @@ pub struct CaptureRequest { pub open_ms: i64, /// The trade's exit, true-UTC milliseconds. pub close_ms: i64, - /// Prints to copy around the trade, per end — [`super::model_margin_ms`] at close time, - /// so the tile holds the tuner's run-up and tail whatever the chart's margin is; see - /// [`ReplayWindow::margin_ms`]. + /// Prints to copy around the trade, per end — [`super::margin_ms`] at close time, whose + /// floor is the tuner's run-up and tail; see [`ReplayWindow::margin_ms`]. pub margin_ms: i64, /// The long-position threshold at close time ([`super::long_position_ms`]): carried so the /// capture's first pass and its settle pass file the same shape whatever the Storage tab @@ -1530,10 +1531,10 @@ fn tick_stage_for( /// trade held ~10 h and closed 40 h ago (retention 48 h) was refused outright although its /// exit's ticks were comfortably inside retention. /// -/// Free, and evaluated BEFORE any request is spent — see [`tick_stage_for`]. Public for the -/// tuner's startup autoload, which asks it before queueing a row at all, so a row the stage -/// would refuse anyway does not pay the candle page ahead of the refusal — the ONE rule, not a -/// second one beside it. +/// Free. Not a gate of the tick stage — a window past the retention is still served from the +/// tiles ([`tick_stage_for`]) — but of the tuner's fetch: its startup autoload asks it before +/// queueing a row at all, and its load before calling a row it holds no tape for fetchable, so a +/// row the venue would refuse anyway pays no candle page ahead of the refusal. /// /// Args: /// route: The trade route in question. @@ -1629,11 +1630,13 @@ fn serve_ticks( ) }); }; - let Some(route) = stage.route else { + // No venue to ask — none has a route, or the focus is past the route's retention: the focus + // is served from what the tiles hold inside it — a capture from the core's archive, the + // tuner's fetch, an earlier window — or the window prints `none`, the reason there is no + // venue to ask. + let serve_held = |none: TickStatus| { hydrate(tiles, persisted.as_ref(), &key, &focus); file_ring(); - // No venue to ask: the focus is served from what the tiles hold inside it — a capture - // from the core's archive — or the window prints that there is no route, as before. let (covered, runs) = { let store = lock_tiles(tiles); let covered = held_coverage(&store, &key, &focus, Coverage::none()); @@ -1687,7 +1690,6 @@ fn serve_ticks( retention_ms: route.retention_ms().unwrap_or(0), }); } - // After the retention refusal, which is free: a window too old for the route pays no read. hydrate(tiles, persisted.as_ref(), &key, &focus); file_ring(); let residual = residual_plan(&plan, &lock_tiles(tiles), &key); diff --git a/crates/moon-core/src/market/trade_replay/worker/tests.rs b/crates/moon-core/src/market/trade_replay/worker/tests.rs index 88303ea0..62ed9353 100644 --- a/crates/moon-core/src/market/trade_replay/worker/tests.rs +++ b/crates/moon-core/src/market/trade_replay/worker/tests.rs @@ -1086,162 +1086,3 @@ fn probe_one_slice_against_the_venue() { &gaps[..gaps.len().min(5)] ); } - -/// `worker.rs:file_core_into_tiles` answering a tiles reader from the ring instead of filing it -/// leaves the model's held query empty and the walk's residual whole: the ring's stretch is -/// paid to the venue again, or — when the ring answered in place of the walk — never fetched -/// at all, and the row stays missing on every fetch (19 of 925 rows on 2026-09-21). -#[test] -fn a_tiles_reader_gets_the_ring_as_core_tiles_the_walk_no_longer_asks_for() { - let history = - crate::market::source::MarketDataSource::new(crate::market::MarketStore::shared(0.0)); - let venue = crate::venue::venue(3).expect("Binance spot"); - let address = ReplayAddress { - history, - venue, - exchange_key: "3:00000000".into(), - cache: None, - }; - let key: TileKey = (address.exchange_key.clone(), "BTCUSDT".into()); - let (open_ms, close_ms) = (100_000_000, 100_120_000); - let window = super::super::replay_window_ms(open_ms, close_ms, 60_000).expect("window"); - let focus = window.focus_spans(); - let (focus_from, focus_to) = focus.hull().expect("one focus"); - let tiles = Mutex::new(TickTileStore::default()); - let asked = std::cell::RefCell::new(Vec::new()); - // The ring holds the position and a little after it, not the lead before the entry. - let ring = (open_ms - 5_000, close_ms + 30_000); - let filed = file_core_into_tiles(&address, "BTCUSDT", &focus, &tiles, |span| { - asked.borrow_mut().push(span); - let from = span.0.max(ring.0); - let to = span.1.min(ring.1); - (from <= to).then(|| crate::market::source::CoreReplayTicks { - ticks: vec![tick(from, 1.0), tick(to, 2.0)], - covered: (from, to), - }) - }); - assert_eq!( - asked.borrow().as_slice(), - focus.spans(), - "one read per focus span" - ); - assert_eq!(filed, vec![(ring.0.max(focus_from), ring.1.min(focus_to))]); - - // What the walk still owes the venue is exactly what the ring did not hold. - let route = TradeRoute::BinanceSpotAggTrades; - let plan = tick_plan(window, route, None, ReplayIntent::Model); - let residual = residual_plan(&plan, &lock_tiles(&tiles), &key); - let residual_ms: i64 = residual.slices.iter().map(|(a, b)| b - a + 1).sum(); - let plan_ms: i64 = plan.slices.iter().map(|(a, b)| b - a + 1).sum(); - assert!( - residual_ms < plan_ms, - "the ring's stretch left the residual" - ); - assert!( - residual - .slices - .iter() - .all(|&(a, b)| b < ring.0 || a > ring.1), - "nothing inside the filed stretch is asked again: {:?}", - residual.slices - ); - // And the tiles say who answered. - let store = lock_tiles(&tiles); - let held = held_coverage(&store, &key, &focus, Coverage::none()); - assert!(held.contains((ring.0.max(focus_from), ring.1.min(focus_to)))); - drop(store); - - // A chart keeps the ring as an answer; a model never takes it in place of the walk. - assert!(!ReplayIntent::Chart.files_core()); - assert!(ReplayIntent::Model.files_core()); -} - -/// The real pager over the real venue, by hand: `MOON_TICKS_PROBE=GateFuturesTrades,GSTOCKBSC_USDT,,` -/// walks that one slice and prints what came back — rows, distinct prints, the largest holes — -/// so a hole or a duplicate in the store can be told apart from one the pager makes today. -#[test] -#[ignore = "asks the venue over the network; run by hand"] -fn probe_one_slice_against_the_venue() { - let Ok(spec) = std::env::var("MOON_TICKS_PROBE") else { - eprintln!("MOON_TICKS_PROBE is not set; nothing to do"); - return; - }; - let parts: Vec<&str> = spec.split(',').collect(); - let [route, market, from_ms, to_ms] = parts[..] else { - panic!("MOON_TICKS_PROBE=,,,"); - }; - let route = match route { - "GateFuturesTrades" => TradeRoute::GateFuturesTrades, - "GateSpotTrades" => TradeRoute::GateSpotTrades, - "OkxHistoryTrades" => TradeRoute::OkxHistoryTrades, - "BinanceUsdMAggTrades" => TradeRoute::BinanceUsdMAggTrades, - other => panic!("unknown route {other}"), - }; - let (from_ms, to_ms): (i64, i64) = (from_ms.parse().unwrap(), to_ms.parse().unwrap()); - let plan = TickPlan { - slices: vec![(from_ms, to_ms)], - trade_len: 1, - focus_len: 1, - }; - let agent = rest::agent(); - let mut observer = FakeObserver::default(); - let verdict = paginate_ticks( - route, - &plan, - TICK_BUDGET, - TICK_PAGE_BUDGET, - || false, - |_| false, - &mut observer, - |from, to, cursor| { - let page = rest::fetch_trades(&agent, route, market, from, to, cursor); - if let Ok(page) = &page { - let (lo, hi) = page.ticks.iter().fold((i64::MAX, i64::MIN), |(lo, hi), t| { - (lo.min(t.time_ms as i64), hi.max(t.time_ms as i64)) - }); - eprintln!( - "PROBE page cursor={cursor:?} rows={} t=+{}..+{} ms next={:?}", - page.ticks.len(), - lo.saturating_sub(from_ms), - hi.saturating_sub(from_ms), - page.next - ); - } - page - }, - ); - let TickVerdict::Ready(harvest) = verdict else { - panic!("abandoned: {verdict:?}"); - }; - let mut keys: Vec<(i64, u32, u32, u8)> = harvest - .ticks - .iter() - .map(|t| { - ( - t.time_ms as i64, - t.price.to_bits(), - t.qty.to_bits(), - t.side as u8, - ) - }) - .collect(); - let rows = keys.len(); - keys.sort_unstable(); - keys.dedup(); - let mut times: Vec = keys.iter().map(|k| k.0).collect(); - times.dedup(); - let mut gaps: Vec<(i64, i64)> = times - .windows(2) - .map(|w| (w[1] - w[0], w[0] - from_ms)) - .collect(); - gaps.sort_unstable_by(|a, b| b.cmp(a)); - eprintln!( - "PROBE {market}: pages={} rows={rows} distinct={} covered={} complete={} stop={:?}\nPROBE largest gaps (ms, at +ms): {:?}", - observer.paces, - keys.len(), - harvest.covered, - harvest.complete, - harvest.stop, - &gaps[..gaps.len().min(5)] - ); -} diff --git a/crates/moon-core/src/session/lifecycle.rs b/crates/moon-core/src/session/lifecycle.rs index 85031726..855db738 100644 --- a/crates/moon-core/src/session/lifecycle.rs +++ b/crates/moon-core/src/session/lifecycle.rs @@ -510,12 +510,11 @@ impl SessionManager { market, open_ms, close_ms, - // The model's margin, not the chart's: the capture is what the tuner reads a - // closed trade's tape from, and its run-up and tail (`model_margin_ms`, at - // least the two pads) must be in the tile or every closed trade re-walks the - // venue for the seconds the ring held for free. A chart margin under the pads - // (5 s is the default) never reaches that far. - margin_ms: crate::market::trade_replay::model_margin_ms(), + // The setting's margin, whose floor is the tuner's run-up and tail + // (`MODEL_PAD_MS`): the capture is what the tuner reads a closed trade's tape + // from, and they must be in the tile or every closed trade re-walks the venue + // for the seconds the ring held for free. + margin_ms: crate::market::trade_replay::margin_ms(), long_position_ms: crate::market::trade_replay::long_position_ms(), }, ); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index e04d71e4..a8eb94c9 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -20,7 +20,7 @@ use super::state::{RowAddress, TapeStatus}; use crate::Backend; use moon_core::db::tuner::ticks::{Deal, model_window}; use moon_core::market::MarketDataSource; -use moon_core::market::trade_replay::{long_position_ms, model_margin_ms}; +use moon_core::market::trade_replay::{long_position_ms, margin_ms}; pub(crate) mod autoload; pub(in crate::analytics::tuner) mod job; @@ -99,7 +99,7 @@ impl FetchResolver { deal: Deal, address: Arc, ) -> Option { - let window = model_window(&deal, model_margin_ms(), long_position_ms())?; + let window = model_window(&deal, margin_ms(), long_position_ms())?; let replay_address = self.source.replay_address(address.core_uid).ok()?; let terms = self .source diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs index 7cc8924d..1bb5b300 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs @@ -40,7 +40,7 @@ use crate::Backend; use moon_core::db::tuner::ticks::{Deal, model_window}; use moon_core::market::trade_replay::venue_caps::trade_route; use moon_core::market::trade_replay::worker::inside_retention; -use moon_core::market::trade_replay::{long_position_ms, model_margin_ms}; +use moon_core::market::trade_replay::{long_position_ms, margin_ms}; /// How far back the autoload looks, whatever the venue documents: the longest retention a /// route names is 90 days, and a month of rows is already thousands of walks. @@ -229,7 +229,7 @@ fn run_pass( for deal in deals { // Stamps that describe no window are the row's own fault, final: not a core that is // still coming, so never retried. - if model_window(&deal, model_margin_ms(), long_position_ms()).is_none() { + if model_window(&deal, margin_ms(), long_position_ms()).is_none() { degenerate += 1; continue; } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 5c5992b1..1e9d38d2 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -36,7 +36,7 @@ use moon_core::feed::types::Tick; use moon_core::market::trade_replay::venue_caps::trade_route; use moon_core::market::trade_replay::worker::inside_retention; use moon_core::market::trade_replay::{ - Coverage, ReplayWindow, TickQuery, TickStatus, long_position_ms, model_margin_ms, query_held, + Coverage, ReplayWindow, TickQuery, TickStatus, long_position_ms, margin_ms, query_held, }; /// How long a held query waits for the worker's answer. The coordinator answers held queries @@ -423,7 +423,7 @@ fn ask_held( mpsc::Receiver, ReplayWindow, )> { - let window = model_window(deal, model_margin_ms(), long_position_ms)?; + let window = model_window(deal, margin_ms(), long_position_ms)?; let (reply, rx) = mpsc::channel(); query_held(TickQuery { exchange_key: address.exchange_key.clone(), @@ -538,7 +538,7 @@ fn unservable_status(address: &RowAddress, deal: &Deal, now_ms: i64) -> Option) { let v = storage_cfg::step_trade_margin_s(self.storage.cfg.trade_replay.margin_s, delta); if self.storage.cfg.trade_replay.margin_s != v { @@ -240,13 +240,14 @@ impl SettingsView { } } - /// The stepper's label for a margin: whole seconds under a minute, whole minutes from - /// there — every step of `TRADE_MARGIN_STEPS_S` is one or the other. + /// The stepper's label for a margin: a whole number of minutes when the step divides by + /// 60, seconds otherwise. 65 s is a step and must not read as "1 min", which is what 60 s + /// already says. fn trades_margin_label(secs: u32) -> String { - if secs < 60 { - t!("storage.trades_sec", s = secs).to_string() - } else { + if secs % 60 == 0 { t!("storage.trades_min", min = secs / 60).to_string() + } else { + t!("storage.trades_sec", s = secs).to_string() } } diff --git a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs index c9b1c287..aaf14232 100644 --- a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs +++ b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup.rs @@ -37,7 +37,7 @@ use moon_core::db::tape_owners::{TapeOwner, read_tape_owners}; use moon_core::db::tuner::ticks::{ORDER_WAIT_CAP_MS, model_window_at, order_open_at}; use moon_core::market::MarketDataSource; use moon_core::market::trade_replay::trade_cache::{self, Inventory, KeepMap, TrimReport}; -use moon_core::market::trade_replay::{Coverage, long_position_ms, model_margin_ms, worker}; +use moon_core::market::trade_replay::{Coverage, long_position_ms, margin_ms, worker}; use moon_core::symbol::Exchange; /// What one pass found — the preview's numbers, or the apply's. @@ -71,18 +71,18 @@ pub(super) struct CleanupContext { /// What a claim is sized with, read once per pass so every row of it is judged alike whatever /// the Storage tab does meanwhile: the margin the tuner's fetch and the close-time capture ask -/// for (the chart's `[trade_replay] margin_s` floored to the model's two pads), and the length -/// from which a position is walked as its two ends. +/// for (`[trade_replay] margin_s`, whose floor is the model's pad), and the length from which a +/// position is walked as its two ends. #[derive(Clone, Copy, Debug)] struct Margins { - model_ms: i64, + margin_ms: i64, long_position_ms: i64, } impl Margins { fn live() -> Self { Self { - model_ms: model_margin_ms(), + margin_ms: margin_ms(), long_position_ms: long_position_ms(), } } @@ -94,7 +94,7 @@ impl Margins { /// before the entry: on the other bound the reach is wider than any claim, and the few rows /// it adds claim nothing. fn reach_s(self) -> i64 { - (self.model_ms + ORDER_WAIT_CAP_MS).div_euclid(1_000) + 1 + (self.margin_ms + ORDER_WAIT_CAP_MS).div_euclid(1_000) + 1 } } @@ -240,7 +240,7 @@ fn build_keep( order_open_at(stamp.buy_ms, stamp.buy_set_ms), stamp.buy_ms, stamp.close_ms, - margins.model_ms, + margins.margin_ms, margins.long_position_ms, ) else { continue; diff --git a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs index 4b61f2aa..b0035cd4 100644 --- a/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs +++ b/crates/moon-ui-gpui/src/settings/storage/trades_cleanup/tests.rs @@ -12,7 +12,7 @@ use super::{ }; const MARGINS: Margins = Margins { - model_ms: 60_000, + margin_ms: 60_000, long_position_ms: 5 * 60_000, }; @@ -48,10 +48,10 @@ fn spans(keep: &KeepMap, k: &(String, String)) -> Vec<(i64, i64)> { keep.get(k).map(|c| c.spans().to_vec()).unwrap_or_default() } -/// A row the catalog names claims its window's focus at the model's margin, on the market the -/// catalog named. +/// A row the catalog names claims its window's focus at the margin, on the market the catalog +/// named. #[test] -fn a_live_row_claims_at_the_model_margin() { +fn a_live_row_claims_at_the_margin() { let inv = inventory(&[("4:0", "ACEUSDT")]); let ace = owner(1, "ACE", 1_000_000, 1_010_000, "MoonShot"); let ben = owner(1, "BEN", 5_000_000, 5_010_000, "MoonShot"); @@ -135,7 +135,8 @@ fn a_row_with_its_orders_creation_claims_from_the_creation() { ); } -/// A position held longer than five minutes claims its two ends, not its middle. +/// A position held longer than five minutes claims its two ends, the margin on both sides of +/// each, not its middle. #[test] fn a_long_position_claims_its_two_ends() { let inv = inventory(&[("4:0", "ACEUSDT")]); @@ -151,8 +152,8 @@ fn a_long_position_claims_its_two_ends() { assert_eq!( spans(&keep, &key("4:0", "ACEUSDT")), vec![ - (1_000_000 - 30_000, 1_000_000 + 30_000), - (4_600_000 - 30_000, 4_600_000 + 30_000) + (1_000_000 - 60_000, 1_000_000 + 60_000), + (4_600_000 - 60_000, 4_600_000 + 60_000) ] ); } @@ -277,7 +278,7 @@ fn the_reach_is_the_margin_and_the_orders_wait_in_whole_seconds() { assert_eq!(MARGINS.reach_s(), 661); assert_eq!( Margins { - model_ms: 7_200_000, + margin_ms: 7_200_000, long_position_ms: 60_000 } .reach_s(), @@ -342,8 +343,8 @@ fn probe_a_copied_data_dir() { .map(|o| (o.core_uid, "USDT".to_string())) .collect(); println!( - "[probe] margin: model {} ms, long position from {} ms", - margins.model_ms, + "[probe] margin: {} ms, long position from {} ms", + margins.margin_ms, moon_core::market::trade_replay::long_position_ms() ); for apply in [false, true] { diff --git a/locales/storage.yml b/locales/storage.yml index c097aba2..50878394 100644 --- a/locales/storage.yml +++ b/locales/storage.yml @@ -129,9 +129,9 @@ storage.trades_min: en: "%{min} min" es: "%{min} min" storage.trades_margin_hint: - ru: "С каждого края сделки; у долгой — вокруг входа и вокруг выхода, середина свечами. Тюнер берёт не меньше 30 с." - en: "At each end of a trade; on a long one, around the entry and around the exit, candles between. The tuner takes at least 30 s." - es: "En cada extremo de una posición; en una larga, alrededor de la entrada y de la salida, velas entre ambas. El afinador toma al menos 30 s." + ru: "С каждого края сделки; у долгой — вокруг входа и вокруг выхода, середина свечами. Не меньше 30 с — столько нужно тюнеру." + en: "At each end of a trade; on a long one, around the entry and around the exit, candles between. At least 30 s — what the tuner needs." + es: "En cada extremo de una posición; en una larga, alrededor de la entrada y de la salida, velas entre ambas. Al menos 30 s: lo que necesita el afinador." storage.trades_long_position: ru: "Долгая сделка от" en: "Long trade from" From 49385539393e177d7223ec90b98a3826b0fbda24 Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 13:26:35 +0200 Subject: [PATCH 22/51] feat(tuner): re-evaluate the coin deltas along the trade as the core 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). --- .../src/db/tuner/ticks/calibrate/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/deals.rs | 12 +- crates/moon-core/src/db/tuner/ticks/deltas.rs | 555 ++++++++++++++++++ .../src/db/tuner/ticks/deltas/tests.rs | 262 +++++++++ crates/moon-core/src/db/tuner/ticks/exit.rs | 30 +- crates/moon-core/src/db/tuner/ticks/line.rs | 2 +- .../src/db/tuner/ticks/line/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/mod.rs | 35 +- crates/moon-core/src/db/tuner/ticks/mshot.rs | 104 +++- crates/moon-core/src/db/tuner/ticks/params.rs | 14 +- crates/moon-core/src/db/tuner/ticks/record.rs | 6 +- .../src/db/tuner/ticks/record/tests.rs | 1 + .../src/db/tuner/ticks/search/tests.rs | 1 + .../src/db/tuner/ticks/stats/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/tests.rs | 51 +- .../src/db/tuner/ticks/tests/real_data.rs | 114 +++- crates/moon-core/src/db/tuner/ticks/verify.rs | 22 +- crates/moon-core/src/market/source/read.rs | 10 + .../src/analytics/tuner/ticks/fetch/job.rs | 8 +- .../src/analytics/tuner/ticks/load.rs | 33 +- .../src/analytics/tuner/ticks/rows/tests.rs | 1 + .../src/analytics/tuner/ticks/state.rs | 5 +- 22 files changed, 1196 insertions(+), 73 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/deltas.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/deltas/tests.rs diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs index 174eca8d..77eb3ee1 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs @@ -28,6 +28,7 @@ fn deal() -> Deal { hook_stated_take_pct: None, step_lag_ms: 0.0, stop_anchor: None, + delta_track: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index b2a82280..ffdbdb2f 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -40,7 +40,7 @@ pub struct DealsRead { /// The delta columns in the order [`Deltas`] is filled below; every one is a `FIELDS` column, /// so the unified source projects it (NULL when the replica lacks it). -const DELTA_COLS: [&str; 13] = [ +const DELTA_COLS: [&str; 16] = [ "d5s", "d1m", "d5m", @@ -54,6 +54,9 @@ const DELTA_COLS: [&str; 13] = [ "btc5mdelta", "exchange1hdelta", "dbtc1m", + "exchange24hdelta", + "pump1h", + "dump1h", ]; /// Read the scope's closed trades as deals: the trades the tuner can be run on @@ -164,7 +167,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { continue; } let mut deltas = Deltas::default(); - let slots: [&mut f64; 13] = [ + let slots: [&mut f64; 16] = [ &mut deltas.d5s, &mut deltas.d1m, &mut deltas.d5m, @@ -178,6 +181,9 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { &mut deltas.btc5m, &mut deltas.market1h, &mut deltas.btc1m, + &mut deltas.market24h, + &mut deltas.pump1h, + &mut deltas.dump1h, ]; for (offset, slot) in slots.into_iter().enumerate() { *slot = num(12 + offset)?; @@ -213,6 +219,8 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { // Filled by `overlay_usdt_profit` off the USDT source, when there is one. profit: None, deltas, + // Filled by the caller that holds the tape (`deltas::track_for`). + delta_track: None, tick: None, pre_spike_ask: None, archived_take: None, diff --git a/crates/moon-core/src/db/tuner/ticks/deltas.rs b/crates/moon-core/src/db/tuner/ticks/deltas.rs new file mode 100644 index 00000000..e840e009 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas.rs @@ -0,0 +1,555 @@ +//! Live coin deltas — the core's `d1m … d24h` re-evaluated along a trade's window the way the +//! core evaluates them, where the report keeps ONE snapshot per trade. +//! +//! What the core computes (`docs-internal/STRATEGY_FORMULAS/deltas.md`: the FAQ, `data/faqru.tsv` +//! :1052, and moonproto's parity port of the core, `state/history_store/derived.rs`): +//! +//! - every coin delta is a RANGE, `(max / min − 1) · 100` over a window — never negative; +//! - the long ones run over the core's five-minute candles, stamped at their END and kept while +//! younger than the window, so a window reaches up to one candle past its name: d15m, d1h, and +//! "d3h" over candles younger than four hours (the FAQ counts that "3ч55м"; the oldest candle +//! began up to 4h05m ago, and on 52 MoonShot trades the report exceeded the range of 3h55m + one +//! candle five times, of 4h + one candle twice) and "d24h" over twenty-five, both never under +//! d1h; +//! - the short ones, d1m and d5m, run over five-second buckets of trades; +//! - the core refreshes them on those five-second buckets. A value holds from one bucket boundary +//! to the next and is computed over what printed BEFORE the boundary: the MoonShot report's +//! d1m is the range of the prints up to the last completed bucket before the fill (12 exact +//! matches of 12 found, 2026-09-23), never the range at the fill itself. +//! +//! Inputs: the tape where it is held, the one-minute bars the tuner's own candle stage keeps in +//! `klines.sqlite` (six hours before every window it fetched), and the recorder's five-minute bars +//! where no minute bar lies. A window the bars do not reach back for is not live and keeps the +//! report's snapshot — d24h as a rule, whose twenty-five hours the cache seldom holds whole. +//! +//! Anchored to the report: at the moment the report stamped its deltas ([`snapshot_ms`]) the track +//! equals the report exactly, and elsewhere it moves by what the tape and the bars moved. The +//! offset absorbs what those inputs cannot see — the phase of the core's candles, and a core that +//! follows the averaged price instead of raw trades (its `DeltasByTrades` switch off). Without +//! the anchor there is no track ([`track_for`]): evaluated alone over the whole replica +//! (2026-09-23, 1 140 trades) the bars and the tape met the report's snapshot to a median of +//! 0.03 pp on d1h, d3h and d24h but 0.15–0.16 pp on d1m, d5m and d15m, which the modifiers' +//! coefficients multiply into the levels. +//! +//! What is not live: the BTC, market, mark-price and price-bug deltas (the report's snapshot — +//! there is no history of them here), and d5s, Pump1h and Dump1h. + +use std::fmt; +use std::sync::Arc; + +use super::entry::KIND_MOONSHOT; +use super::{Deal, Deltas}; +use crate::feed::types::Tick; +use crate::market::kline_cache::KlineCache; +use crate::market::trade_replay::Coverage; + +/// The core's refresh step of the coin deltas, milliseconds — see the module doc. The track's +/// points sit on multiples of it, so a caller can tell two moments of one value apart by +/// `t_ms.div_euclid(STEP_MS)` alone. +pub const STEP_MS: i64 = 5_000; + +const MINUTE_MS: i64 = 60_000; + +/// How far a window over the core's candles reaches past its name: one candle. +const CANDLE_MS: i64 = 5 * MINUTE_MS; + +/// The longest hole between two bars still read as continuous history. A venue may skip a +/// minute nobody traded in; a hole a candle wide is where the history really stops. +const MAX_BAR_GAP_MS: i64 = CANDLE_MS; + +/// How far before a window the widest delta reaches: "d24h", twenty-five hours of candles plus +/// one. +pub const LOOKBACK_MS: i64 = 25 * 60 * MINUTE_MS + CANDLE_MS; + +/// The coin deltas the track re-evaluates, per cent, as `orders_rep` names them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CoinDeltas { + pub d1m: f64, + pub d5m: f64, + pub d15m: f64, + pub d1h: f64, + pub d3h: f64, + pub d24h: f64, +} + +impl CoinDeltas { + fn of(d: &Deltas) -> Self { + Self { + d1m: d.d1m, + d5m: d.d5m, + d15m: d.d15m, + d1h: d.d1h, + d3h: d.d3h, + d24h: d.d24h, + } + } + + fn fields_mut(&mut self) -> [&mut f64; 6] { + [ + &mut self.d1m, + &mut self.d5m, + &mut self.d15m, + &mut self.d1h, + &mut self.d3h, + &mut self.d24h, + ] + } + + fn fields(&self) -> [f64; 6] { + [self.d1m, self.d5m, self.d15m, self.d1h, self.d3h, self.d24h] + } +} + +/// The ranges the core keeps, in the order the evaluation walks them. "d3h" and "d24h" are not +/// windows of their own: each is the wider range, never under d1h. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Window { + M1, + M5, + M15, + H1, + H4, + H25, +} + +const WINDOWS: [Window; 6] = [ + Window::M1, + Window::M5, + Window::M15, + Window::H1, + Window::H4, + Window::H25, +]; + +impl Window { + /// How far back from an evaluation moment the window takes what printed. The short ones are + /// whole five-second buckets, so their reach is their name; a window over the candles + /// reaches one candle further (the module doc; measured on 86 trades, d15m's median error + /// fell from 0.21 pp to 0.02 pp with the extra candle). + fn reach_ms(self) -> i64 { + match self { + Self::M1 => MINUTE_MS, + Self::M5 => 5 * MINUTE_MS, + Self::M15 => 15 * MINUTE_MS + CANDLE_MS, + Self::H1 => 60 * MINUTE_MS + CANDLE_MS, + Self::H4 => 4 * 60 * MINUTE_MS + CANDLE_MS, + Self::H25 => LOOKBACK_MS, + } + } +} + +/// One bar of history: the extremes printed over `[from_ms, to_ms)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bar { + pub from_ms: i64, + pub to_ms: i64, + pub high: f64, + pub low: f64, +} + +/// What printed over a stretch — a bar, or one print — for the sliding windows. +#[derive(Clone, Copy, Debug)] +struct Item { + /// Exclusive end: the item is complete, and joins a window, at a boundary not before it. + end_ms: i64, + high: f64, + low: f64, +} + +/// The track over one covered stretch of the tape. +#[derive(Clone, Debug, PartialEq)] +struct Segment { + /// The first evaluation moment, a multiple of [`STEP_MS`]; point `i` holds from + /// `first_ms + i · STEP_MS` for one step. + first_ms: i64, + values: Vec, + /// Per field of [`CoinDeltas`]: whether the history reached back far enough for it to be + /// live here. A field that is not keeps the report's snapshot. + live: [bool; 6], +} + +impl Segment { + fn point(&self, t_ms: i64) -> Option<&CoinDeltas> { + if t_ms < self.first_ms { + return None; + } + self.values + .get(usize::try_from((t_ms - self.first_ms).div_euclid(STEP_MS)).ok()?) + } +} + +/// The coin deltas of one trade along its window — see the module doc. Built once per trade by +/// the caller that holds its tape ([`track_for`]) and read by the models at every moment they +/// place something ([`Deal::deltas_at`]). +#[derive(Clone, PartialEq)] +pub struct DeltaTrack { + segments: Vec, +} + +impl fmt::Debug for DeltaTrack { + /// A summary: the points themselves are thousands of numbers nobody reads in a log. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let points: usize = self.segments.iter().map(|s| s.values.len()).sum(); + f.debug_struct("DeltaTrack") + .field("segments", &self.segments.len()) + .field("points", &points) + .finish() + } +} + +impl DeltaTrack { + /// Evaluate the coin deltas over every covered stretch of the tape. + /// + /// Args: + /// bars: History bars, ascending by start. A bar that overlaps the tape's coverage is + /// left out — the prints there are the truth, and a bar would carry prints from after + /// the moment it is read at. + /// ticks: The tape, ascending. + /// covered: The spans the tape covers, ascending; the track is evaluated inside them. + /// eval: The stretch the models read deltas over (see [`eval_span`]); the covered spans + /// are clipped to it, which is what bounds the track's size on a wide margin. + /// anchor: The report's snapshot and the moment it was stamped ([`snapshot_ms`]); the + /// track is shifted to equal it there. `None` leaves the evaluation as it is. + /// + /// Returns: + /// The track, or `None` when no field is live anywhere — the caller keeps the snapshot. + pub fn build( + bars: &[Bar], + ticks: &[Tick], + covered: &[(i64, i64)], + eval: (i64, i64), + anchor: Option<(i64, &Deltas)>, + ) -> Option { + let overlaps_tape = |bar: &Bar| { + covered + .iter() + .any(|&(from, to)| bar.from_ms < to && bar.to_ms > from) + }; + let history: Vec = bars + .iter() + .filter(|b| b.low > 0.0 && b.high >= b.low && b.to_ms > b.from_ms && !overlaps_tape(b)) + .copied() + .collect(); + let mut items: Vec = history + .iter() + .map(|b| Item { + end_ms: b.to_ms, + high: b.high, + low: b.low, + }) + .collect(); + items.extend(ticks.iter().filter_map(|t| { + let price = f64::from(t.price); + (price.is_finite() && price > 0.0).then_some(Item { + end_ms: t.time_ms as i64 + 1, + high: price, + low: price, + }) + })); + items.sort_by_key(|i| i.end_ms); + let continuous = continuous_spans(&history, covered); + + let mut windows = WINDOWS.map(|w| Extremes::new(w.reach_ms())); + let mut next = 0usize; + let mut segments = Vec::new(); + for &(span_from, span_to) in covered { + let (from, to) = (span_from.max(eval.0), span_to.min(eval.1)); + let first_ms = ceil_step(from); + if first_ms > to { + continue; + } + // How far the history reaches back unbroken from this stretch. + let history_from = continuous + .iter() + .find(|&&(a, b)| a <= span_from && span_to <= b) + .map_or(span_from, |&(a, _)| a); + let reaches = WINDOWS.map(|w| history_from <= first_ms - w.reach_ms()); + let [m1, m5, m15, h1, h4, h25] = reaches; + let live = [m1, m5, m15, h1, h1 && h4, h1 && h25]; + let mut values = Vec::new(); + let mut at = first_ms; + while at <= to { + while next < items.len() && items[next].end_ms <= at { + for window in &mut windows { + window.push(next, &items); + } + next += 1; + } + let [r1, r5, r15, rh1, rh4, rh25] = + windows.each_mut().map(|w| w.range_at(at, &items)); + values.push(CoinDeltas { + d1m: r1, + d5m: r5, + d15m: r15, + d1h: rh1, + d3h: rh1.max(rh4), + d24h: rh1.max(rh25), + }); + at += STEP_MS; + } + segments.push(Segment { + first_ms, + values, + live, + }); + } + let mut track = Self { segments }; + if let Some((at, snapshot)) = anchor { + // An anchor asked for and not found — a hole in the tape at the stamp — is no track: + // the evaluation alone is not the core's number (see `track_for`). + if !track.anchor(at, &CoinDeltas::of(snapshot)) { + return None; + } + } + track + .segments + .iter() + .any(|s| s.live.iter().any(|&l| l)) + .then_some(track) + } + + /// Shift every live field by what separates the evaluation from the report's snapshot at + /// the moment it was stamped. A field the report holds at exactly zero is not the core's + /// range — no market holds still for an hour — but a field it never filled, and it is not + /// made live: the model keeps the report's zero, as it did before the track. + /// + /// Returns whether the track reaches the stamp at all; nothing is shifted when it does not. + /// A field is left live only where it was live AT the stamp as well: an offset read off a + /// field the history did not reach there is no offset. + fn anchor(&mut self, at: i64, snapshot: &CoinDeltas) -> bool { + let Some((estimate, live_at_stamp)) = self.at(at) else { + return false; + }; + let snap = snapshot.fields(); + let est = estimate.fields(); + for segment in &mut self.segments { + for (field, live) in segment.live.iter_mut().enumerate() { + *live = *live && live_at_stamp[field] && snap[field] != 0.0; + } + for value in &mut segment.values { + for (field, slot) in value.fields_mut().into_iter().enumerate() { + *slot = (*slot + snap[field] - est[field]).max(0.0); + } + } + } + true + } + + /// The deltas at a moment: the snapshot with every field this track has live there + /// replaced. Outside the covered stretches, the snapshot as it is. + pub fn apply(&self, t_ms: i64, snapshot: &Deltas) -> Deltas { + let Some((values, live)) = self.at(t_ms) else { + return *snapshot; + }; + let pick = |field: usize, own: f64, snap: f64| if live[field] { own } else { snap }; + Deltas { + d1m: pick(0, values.d1m, snapshot.d1m), + d5m: pick(1, values.d5m, snapshot.d5m), + d15m: pick(2, values.d15m, snapshot.d15m), + d1h: pick(3, values.d1h, snapshot.d1h), + d3h: pick(4, values.d3h, snapshot.d3h), + d24h: pick(5, values.d24h, snapshot.d24h), + ..*snapshot + } + } + + /// The evaluated coin deltas at a moment and which of them are live there, or `None` + /// outside the covered stretches. + pub fn at(&self, t_ms: i64) -> Option<(CoinDeltas, [bool; 6])> { + self.segments + .iter() + .find_map(|s| s.point(t_ms).map(|v| (*v, s.live))) + } +} + +/// The sliding extremes of one window: two monotone queues of item indices, the highs +/// decreasing and the lows increasing from the front, so the front of each is the extreme of +/// what the window holds. +struct Extremes { + reach_ms: i64, + highs: std::collections::VecDeque, + lows: std::collections::VecDeque, +} + +impl Extremes { + fn new(reach_ms: i64) -> Self { + Self { + reach_ms, + highs: std::collections::VecDeque::new(), + lows: std::collections::VecDeque::new(), + } + } + + fn push(&mut self, index: usize, items: &[Item]) { + let item = items[index]; + while self + .highs + .back() + .is_some_and(|&i| items[i].high <= item.high) + { + self.highs.pop_back(); + } + self.highs.push_back(index); + while self.lows.back().is_some_and(|&i| items[i].low >= item.low) { + self.lows.pop_back(); + } + self.lows.push_back(index); + } + + /// The range at a boundary, per cent: what ended inside `(at − reach, at]` — a bar that + /// began before the window's start but ended inside it counts whole, as the core's candle + /// does. Zero when the window holds nothing. + fn range_at(&mut self, at: i64, items: &[Item]) -> f64 { + let start = at - self.reach_ms; + while self + .highs + .front() + .is_some_and(|&i| items[i].end_ms <= start) + { + self.highs.pop_front(); + } + while self.lows.front().is_some_and(|&i| items[i].end_ms <= start) { + self.lows.pop_front(); + } + match (self.highs.front(), self.lows.front()) { + (Some(&h), Some(&l)) if items[l].low > 0.0 => { + (items[h].high / items[l].low - 1.0) * 100.0 + } + _ => 0.0, + } + } +} + +/// The stretches of time the history covers without a break: the bars joined across holes up +/// to [`MAX_BAR_GAP_MS`], and the tape's own coverage. +fn continuous_spans(bars: &[Bar], covered: &[(i64, i64)]) -> Vec<(i64, i64)> { + let mut spans: Vec<(i64, i64)> = bars + .iter() + .map(|b| (b.from_ms, b.to_ms)) + .chain(covered.iter().copied()) + .collect(); + spans.sort_unstable(); + let mut out: Vec<(i64, i64)> = Vec::new(); + for (from, to) in spans { + match out.last_mut() { + Some(last) if from <= last.1 + MAX_BAR_GAP_MS => last.1 = last.1.max(to), + _ => out.push((from, to)), + } + } + out +} + +/// The first multiple of [`STEP_MS`] at or after a moment. +fn ceil_step(t_ms: i64) -> i64 { + t_ms.div_euclid(STEP_MS) * STEP_MS + + if t_ms.rem_euclid(STEP_MS) == 0 { + 0 + } else { + STEP_MS + } +} + +/// Where the models read a deal's deltas: from the run-up before its entry order's life began — +/// the creation, or the buy where the report stamps no creation the tape reaches — through the +/// close. An entry model placing its order off an earlier print, or a variant filling after the +/// close, reads the snapshot there. +pub fn eval_span(deal: &Deal) -> (i64, i64) { + let open = deal.order_open_ms().unwrap_or(deal.buy_ms); + (open - super::RUN_UP_MS, deal.close_ms) +} + +/// When the report stamped a trade's deltas (FAQ :423): at the buy for MoonShot, at the detect +/// and the placement of the buy order for every other kind — its creation stamp, where the +/// report has one. `None` for a creation the report does not stamp. +pub fn snapshot_ms(deal: &Deal) -> Option { + if deal.kind == KIND_MOONSHOT { + Some(deal.buy_ms) + } else { + deal.buy_set_ms + } +} + +/// The history bars a track over `covered` reads off the kline cache: the one-minute bars, and +/// the five-minute ones wherever no minute bar lies. A read the cache did not answer in time is +/// an empty history, and the windows it would have fed stay on the snapshot. +/// +/// Args: +/// cache: The terminal's kline cache. +/// exchange_key: The cache's exchange key of the deal's core (`"{code}:{dex:08x}"`). +/// market: The market as the core spells it. +/// covered: The tape's coverage the track is evaluated over. +pub fn read_bars( + cache: &KlineCache, + exchange_key: &str, + market: &str, + covered: &Coverage, +) -> Vec { + let Some((from, to)) = covered.hull() else { + return Vec::new(); + }; + let from = from - LOOKBACK_MS - CANDLE_MS; + let read = |kind_min: u32| { + let span_ms = i64::from(kind_min) * MINUTE_MS; + cache + .read_range(exchange_key, market, kind_min, from, to) + .unwrap_or_default() + .into_iter() + .filter(|c| c.t_open_ms.is_finite()) + .map(|c| { + let from_ms = c.t_open_ms as i64; + Bar { + from_ms, + to_ms: from_ms + span_ms, + high: f64::from(c.high), + low: f64::from(c.low), + } + }) + .collect::>() + }; + let minutes = read(1); + let mut bars = minutes.clone(); + bars.extend(read(5).into_iter().filter(|five| { + !minutes + .iter() + .any(|m| m.from_ms >= five.from_ms && m.from_ms < five.to_ms) + })); + bars.sort_by_key(|b| b.from_ms); + bars +} + +/// The track of one deal, from its tape and the kline cache — the one call the tuner's table and +/// the `real_data` bench both make, so what the bench measures is what the table replays. +/// +/// Args: +/// cache: The terminal's kline cache. +/// exchange_key: The cache's exchange key of the deal's core. +/// market: The market as the core spells it. +/// deal: The trade, for its snapshot and the moment it was stamped. +/// ticks: The tape, ascending. +/// covered: The tape's coverage. +pub fn track_for( + cache: &KlineCache, + exchange_key: &str, + market: &str, + deal: &Deal, + ticks: &[Tick], + covered: &Coverage, +) -> Option> { + // Only a track anchored on the report is the core's number: evaluated alone, it placed 89 + // MoonHook takes further from the core's than the snapshot did (median 0.19 pp against + // 0.09, 2026-09-23), while anchored at the order's creation it placed the 21 stamped ones + // closer (11 against 7). A trade without a stamp the tape reaches keeps the snapshot. + let at = snapshot_ms(deal)?; + let bars = read_bars(cache, exchange_key, market, covered); + DeltaTrack::build( + &bars, + ticks, + covered.spans(), + eval_span(deal), + Some((at, &deal.deltas)), + ) + .map(Arc::new) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs b/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs new file mode 100644 index 00000000..9e21d7e0 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs @@ -0,0 +1,262 @@ +use super::*; +use crate::feed::types::Side; + +const T0: i64 = 1_790_000_000_000; // a multiple of STEP_MS + +fn tick(t_ms: i64, price: f32) -> Tick { + Tick { + time_ms: t_ms as f64, + price, + qty: 1.0, + side: Side::Buy, + } +} + +/// Minute bars at a flat price from `from` to `to`, with one bar's extremes overridden. +fn flat_bars(from: i64, to: i64, price: f64) -> Vec { + (from..to) + .step_by(MINUTE_MS as usize) + .map(|t| Bar { + from_ms: t, + to_ms: t + MINUTE_MS, + high: price, + low: price, + }) + .collect() +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +#[test] +fn a_delta_is_the_range_of_what_printed_before_the_boundary() { + // History a whole day back, flat at 100; the tape from T0 on. + let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); + let ticks = [tick(T0 + 1_000, 100.0), tick(T0 + 7_000, 110.0)]; + let covered = [(T0, T0 + 60_000)]; + let track = DeltaTrack::build(&bars, &ticks, &covered, (T0, T0 + 60_000), None).unwrap(); + // The print at +7 s joins at the next boundary, +10 s — not in the bucket it printed in. + let (at_5s, _) = track.at(T0 + 9_999).unwrap(); + assert!(close(at_5s.d1m, 0.0), "{at_5s:?}"); + let (at_10s, live) = track.at(T0 + 10_000).unwrap(); + assert!(close(at_10s.d1m, 10.0), "{at_10s:?}"); + assert!( + close(at_10s.d24h, 10.0) && close(at_10s.d3h, 10.0), + "{at_10s:?}" + ); + assert_eq!(live, [true; 6]); +} + +#[test] +fn a_candle_window_reaches_one_candle_past_its_name() { + // A spike to 120 in a bar that ended 19 minutes before T0: inside d15m's reach (20 min), and + // out of it once the reach has passed it. + let mut bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); + let spike_end = T0 - 19 * MINUTE_MS; + for b in &mut bars { + if b.to_ms == spike_end { + b.high = 120.0; + } + } + let ticks = [tick(T0 + 1_000, 100.0)]; + let covered = [(T0, T0 + 5 * MINUTE_MS)]; + let track = DeltaTrack::build(&bars, &ticks, &covered, (T0, T0 + 5 * MINUTE_MS), None).unwrap(); + let (now, _) = track.at(T0).unwrap(); + assert!(close(now.d15m, 20.0), "{now:?}"); + assert!(close(now.d5m, 0.0), "{now:?}"); + // One minute later the bar ended 20 minutes ago: out of the window. + let (later, _) = track.at(T0 + MINUTE_MS).unwrap(); + assert!(close(later.d15m, 0.0), "{later:?}"); + // Still inside d1h, and the long ones never read below it. + assert!(close(later.d1h, 20.0) && close(later.d3h, 20.0) && close(later.d24h, 20.0)); +} + +#[test] +fn a_window_the_history_does_not_reach_back_for_keeps_the_snapshot() { + // Six hours of bars: d1m … d3h live, d24h not. + let bars = flat_bars(T0 - 6 * 60 * MINUTE_MS, T0, 100.0); + let ticks = [tick(T0 + 1_000, 105.0)]; + let covered = [(T0, T0 + 60_000)]; + let track = DeltaTrack::build(&bars, &ticks, &covered, (T0, T0 + 60_000), None).unwrap(); + let (_, live) = track.at(T0 + 5_000).unwrap(); + assert_eq!(live, [true, true, true, true, true, false]); + let snapshot = Deltas { + d24h: 42.0, + btc1h: 0.3, + ..Deltas::default() + }; + let applied = track.apply(T0 + 5_000, &snapshot); + assert!(close(applied.d1h, 5.0), "{applied:?}"); + assert!(close(applied.d24h, 42.0), "the snapshot: {applied:?}"); + assert!(close(applied.btc1h, 0.3), "never live: {applied:?}"); + // Outside the covered stretch, the snapshot whole. + assert_eq!(track.apply(T0 + 10 * MINUTE_MS, &snapshot), snapshot); + // A hole of more than a candle in the history is where it stops. + let mut holed = flat_bars(T0 - 6 * 60 * MINUTE_MS, T0, 100.0); + holed.retain(|b| !(T0 - 30 * MINUTE_MS..T0 - 20 * MINUTE_MS).contains(&b.from_ms)); + let track = DeltaTrack::build(&holed, &ticks, &covered, (T0, T0 + 60_000), None).unwrap(); + let (_, live) = track.at(T0 + 5_000).unwrap(); + assert_eq!(live, [true, true, true, false, false, false]); +} + +#[test] +fn nothing_live_is_no_track() { + let ticks = [tick(T0 + 1_000, 100.0)]; + assert!( + DeltaTrack::build(&[], &ticks, &[(T0, T0 + 30_000)], (T0, T0 + 30_000), None).is_none() + ); +} + +#[test] +fn the_anchor_puts_the_track_on_the_report_at_its_stamp() { + let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); + let ticks = [tick(T0 + 1_000, 100.0), tick(T0 + 12_000, 104.0)]; + let covered = [(T0, T0 + 60_000)]; + // The report says 1 % on d1m at +5 s (the evaluation says 0) and never filled d15m. + let snapshot = Deltas { + d1m: 1.0, + d5m: 0.5, + d15m: 0.0, + d1h: 2.0, + d3h: 3.0, + d24h: 9.0, + ..Deltas::default() + }; + let track = DeltaTrack::build( + &bars, + &ticks, + &covered, + (T0, T0 + 60_000), + Some((T0 + 5_000, &snapshot)), + ) + .unwrap(); + let at_stamp = track.apply(T0 + 5_000, &snapshot); + assert_eq!(at_stamp, snapshot); + // After the 4 % print the track moved by 4 from where the report stood. + let later = track.apply(T0 + 15_000, &snapshot); + assert!( + close(later.d1m, 5.0) && close(later.d24h, 13.0), + "{later:?}" + ); + assert!( + close(later.d15m, 0.0), + "a field the report never filled: {later:?}" + ); +} + +#[test] +fn an_anchor_the_track_does_not_reach_is_no_track() { + let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); + let ticks = [tick(T0 + 1_000, 100.0)]; + let snapshot = Deltas { + d1m: 1.0, + ..Deltas::default() + }; + // Stamped before the tape begins: nothing to put the track on the report with. + let stamp = Some((T0 - 60_000, &snapshot)); + assert!( + DeltaTrack::build( + &bars, + &ticks, + &[(T0, T0 + 60_000)], + (T0, T0 + 60_000), + stamp + ) + .is_none() + ); + // And a deal the report never stamped gets none either. + let mut deal = crate::db::tuner::ticks::tests::deal(); + deal.kind = "MoonHook".into(); + deal.buy_set_ms = None; + assert_eq!(snapshot_ms(&deal), None); +} + +#[test] +fn a_field_not_live_at_the_stamp_is_not_live_anywhere() { + // Two stretches: the first reached back six hours, the second a whole day — d24h is live + // only in the second, and the stamp in the first gives it no offset. + let bars = flat_bars(T0 - 6 * 60 * MINUTE_MS, T0, 100.0); + let ticks = [tick(T0 + 1_000, 100.0)]; + let snapshot = Deltas { + d1m: 1.0, + d5m: 1.0, + d15m: 1.0, + d1h: 1.0, + d3h: 1.0, + d24h: 7.0, + ..Deltas::default() + }; + let track = DeltaTrack::build( + &bars, + &ticks, + &[(T0, T0 + 60_000)], + (T0, T0 + 60_000), + Some((T0 + 5_000, &snapshot)), + ) + .unwrap(); + let (_, live) = track.at(T0 + 30_000).unwrap(); + assert!(!live[5], "{live:?}"); + assert!(close(track.apply(T0 + 30_000, &snapshot).d24h, 7.0)); +} + +#[test] +fn a_bar_under_the_tape_is_not_history() { + // A bar overlapping the tape carries prints from after the moment it would be read at. + let mut bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); + bars.push(Bar { + from_ms: T0, + to_ms: T0 + MINUTE_MS, + high: 150.0, + low: 100.0, + }); + let ticks = [tick(T0 + 1_000, 100.0)]; + let covered = [(T0, T0 + MINUTE_MS)]; + let track = DeltaTrack::build(&bars, &ticks, &covered, (T0, T0 + MINUTE_MS), None).unwrap(); + let (late, _) = track.at(T0 + 55_000).unwrap(); + assert!(close(late.d1h, 0.0), "{late:?}"); +} + +#[test] +fn the_track_is_evaluated_only_where_the_models_read_it() { + let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); + let ticks = [tick(T0 + 1_000, 100.0)]; + let covered = [(T0, T0 + 60 * MINUTE_MS)]; + let track = DeltaTrack::build( + &bars, + &ticks, + &covered, + (T0 + MINUTE_MS, T0 + 2 * MINUTE_MS), + None, + ) + .unwrap(); + assert!(track.at(T0 + 30_000).is_none()); + assert!(track.at(T0 + MINUTE_MS).is_some()); + assert!(track.at(T0 + 3 * MINUTE_MS).is_none()); +} + +#[test] +fn the_snapshot_is_stamped_at_the_buy_for_moonshot_and_at_the_creation_otherwise() { + let mut deal = crate::db::tuner::ticks::tests::deal(); + deal.buy_set_ms = Some(deal.buy_ms - 60_000); + deal.kind = KIND_MOONSHOT.into(); + assert_eq!(snapshot_ms(&deal), Some(deal.buy_ms)); + deal.kind = "MoonHook".into(); + assert_eq!(snapshot_ms(&deal), Some(deal.buy_ms - 60_000)); + deal.buy_set_ms = None; + assert_eq!(snapshot_ms(&deal), None); +} + +#[test] +fn a_deal_reads_its_track_and_falls_back_to_the_snapshot() { + let mut deal = crate::db::tuner::ticks::tests::deal(); + deal.deltas.d1m = 3.0; + assert!(close(deal.deltas_at(deal.buy_ms).d1m, 3.0)); + let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); + let ticks = [tick(T0 + 1_000, 100.0), tick(T0 + 2_000, 102.0)]; + let track = + DeltaTrack::build(&bars, &ticks, &[(T0, T0 + 60_000)], (T0, T0 + 60_000), None).unwrap(); + deal.delta_track = Some(Arc::new(track)); + assert!(close(deal.deltas_at(T0 + 5_000).d1m, 2.0)); + assert!(close(deal.deltas_at(T0 - 60_000).d1m, 3.0)); +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index 3b73f4b2..5fa48907 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -228,7 +228,7 @@ impl<'a> ExitModel<'a> { // inside out. The rules that legitimately sell below the entry are the moving ones // (`PriceDownAllowedDrop`, a negative `SellShotDistance`), and they get there by // stepping down from the take, not by starting underneath it. - let pct = (self.base_take_pct(deal) + self.modifier_pct(deal)).max(0.0); + let pct = (self.base_take_pct(deal) + self.modifier_pct(deal, fill.t_ms)).max(0.0); let by_pct = fill.price * pct / 100.0; let mut take = if deal.is_long() { fill.price + by_pct @@ -268,9 +268,9 @@ impl<'a> ExitModel<'a> { } /// What the delta modifiers add to the sell level, per cent — the capped sum times - /// `SellModifier`, per the FAQ. - fn modifier_pct(&self, deal: &Deal) -> f64 { - modifier_sum(self.params, deal) * self.params.sell_modifier + /// `SellModifier`, per the FAQ — as the deltas stood when the sell was placed, at `at_ms`. + fn modifier_pct(&self, deal: &Deal, at_ms: i64) -> f64 { + modifier_sum(self.params, deal, at_ms) * self.params.sell_modifier } /// Whether a walk under these parameters knows where the trade's take stands — the level @@ -344,17 +344,19 @@ impl<'a> ExitModel<'a> { /// One sum, two consumers — the sell level through `SellModifier` and the stop through /// `StopLossModifier` — because the core computes it once and spends it on both (FAQ). /// -/// Off the report it cannot be exact: the coefficients are the strategy's, but the deltas are -/// the ONE snapshot the report stores per trade, while the core re-evaluates them live. On the -/// stop the verdict absorbs the residual in its level tolerance (`verify::STOP_PRICE_TOLERANCE`); -/// on the sell level it is a limit of the input, and a take it moves too far fails its verdict -/// and keeps the trade out of the search. +/// The core sums the deltas as they stand when it places the sell: on 121 of its printed sums +/// (2026-09-22) the report's snapshot, stamped at the entry order's placement for every kind but +/// MoonShot, drifted from the core's number the more, the longer the entry order waited. So the +/// sum is read at `at_ms` through the deal's live coin deltas ([`Deal::deltas_at`]); the BTC, +/// market, mark and price-bug terms stay the snapshot, and on the stop the verdict absorbs their +/// residual in its level tolerance (`verify::STOP_PRICE_TOLERANCE`). /// /// Args: /// params: The sell parameters, for the coefficients and the ceiling. /// deal: The trade, for its deltas. -pub fn modifier_sum(params: &ExitParams, deal: &Deal) -> f64 { - let sum = params.sell_mods.near_addition(&deal.deltas); +/// at_ms: When the sell was placed — the fill. +pub fn modifier_sum(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { + let sum = params.sell_mods.near_addition(&deal.deltas_at(at_ms)); if params.max_modifier > 0.0 { sum.min(params.max_modifier) } else { @@ -379,11 +381,13 @@ pub fn modifier_sum(params: &ExitParams, deal: &Deal) -> f64 { /// Args: /// params: The sell parameters. /// deal: The trade, for its deltas. -pub fn stop_pct(params: &ExitParams, deal: &Deal) -> f64 { +/// at_ms: When the sell was placed — the fill; see [`modifier_sum`]. +pub fn stop_pct(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { if params.stop_loss_pct == 0.0 || params.stop_loss_modifier == 0.0 { return params.stop_loss_pct; } - let adjusted = params.stop_loss_pct - modifier_sum(params, deal) * params.stop_loss_modifier; + let adjusted = + params.stop_loss_pct - modifier_sum(params, deal, at_ms) * params.stop_loss_modifier; // Same side as configured, or nothing at all. if adjusted == 0.0 || adjusted.is_sign_negative() != params.stop_loss_pct.is_sign_negative() { return 0.0; diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs index fe3dd744..ab811bfd 100644 --- a/crates/moon-core/src/db/tuner/ticks/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/line.rs @@ -370,7 +370,7 @@ pub fn walk_held( // fires it. `over` mirrors the sign for a short, so only the distance is adjusted here; the // level is NOT snapped to the price grid, unlike every level that reaches the exchange — // a stop is the core's own trigger for a market sell, and nothing about it is ever placed. - let stop = stop_pct(params, deal); + let stop = stop_pct(params, deal, fill.t_ms); let stop_on = stop != 0.0; let stop_level = side.over(fill.price, stop); let stop_from = fill.t_ms + (params.stop_loss_delay_s.max(0.0) * 1000.0) as i64; diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index 9eceadb9..60955a9a 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -43,6 +43,7 @@ fn deal(short: bool) -> Deal { hook_stated_take_pct: None, step_lag_ms: 0.0, stop_anchor: None, + delta_track: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index ce393537..e8722624 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -27,6 +27,7 @@ use crate::market::trade_replay::{Coverage, ReplayWindow, replay_window_ms}; pub mod calibrate; pub mod deals; +pub mod deltas; pub mod entry; pub mod exit; pub mod hook; @@ -115,11 +116,14 @@ pub fn round_to_step(level: f64, tick: f64) -> f64 { (level / tick).round() * tick } -/// The report-side deltas the MoonShot modifiers read, as of the BUY of the trade. +/// The report-side deltas the delta modifiers read (`MShotAdd*` on the entry corridor, `Add*` +/// on the sell and the stop). /// -/// The report stamps them once, at the buy; the model treats them as constant over the window, -/// which is a stated assumption — on a 5-minute window a 1-hour delta barely moves, a 1-minute -/// delta can. All values are per cent, exactly as `orders_rep` stores them. +/// The report stamps them once — at the buy for MoonShot, at the detect and the order's +/// placement for every other kind (FAQ :423) — while the core re-reads them live. The coin's +/// own ranges (`d1m … d24h`) are re-evaluated along the window where the caller could build a +/// [`deltas::DeltaTrack`] for the deal ([`Deal::deltas_at`]); the rest stay this snapshot. All +/// values are per cent, exactly as `orders_rep` stores them. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Deltas { /// The last five seconds' move (`d5s`) — the spike itself; shown, not a modifier input. @@ -141,8 +145,14 @@ pub struct Deltas { /// BTC 1-minute delta (`dbtc1m`) — read by the sell side's `AddBTC1mDelta`; MoonShot's /// corridor family has no term for it. pub btc1m: f64, - /// Exchange-wide 1-hour delta (`exchange1hdelta`). + /// Exchange-wide 1-hour delta (`exchange1hdelta`), signed. pub market1h: f64, + /// Exchange-wide 24-hour delta (`exchange24hdelta`), signed — read by `AddMarket24Delta`. + pub market24h: f64, + /// The hour's rise from the price an hour ago to its high (`pump1h`) — read by `AddPump1h`. + pub pump1h: f64, + /// The hour's fall from the price an hour ago to its low (`dump1h`) — read by `AddDump1h`. + pub dump1h: f64, } /// One closed trade as the model needs it — the report row narrowed to the fields the tape @@ -197,7 +207,13 @@ pub struct Deal { /// profit column alone reads it — the KPI stays in the scope's own unit through /// `fact_pnl` and `spent`. pub profit: Option, + /// The report's snapshot of the deltas; read through [`Deal::deltas_at`], never directly by a + /// model, so the live track applies wherever the deal has one. pub deltas: Deltas, + /// The coin's own deltas along the window, as the core re-evaluated them + /// ([`deltas::track_for`]); filled by the caller that holds the tape, before the other model + /// inputs (the stop anchor reads the stop through it). `None` keeps the snapshot everywhere. + pub delta_track: Option>, /// Price step of the market, when the caller could resolve it (the live catalog, or /// [`infer_tick`] over the window). `None` disables the step-bound rules and rounds nothing. pub tick: Option, @@ -254,6 +270,15 @@ impl Deal { pub fn order_open_ms(&self) -> Option { order_open_at(self.buy_ms, self.buy_set_ms) } + + /// The deltas as the core held them at a moment: the live track's coin ranges where the + /// deal has one, the report's snapshot for everything else. + pub fn deltas_at(&self, t_ms: i64) -> Deltas { + match &self.delta_track { + Some(track) => track.apply(t_ms, &self.deltas), + None => self.deltas, + } + } } /// When an entry order's life began, for a model that replays it whole: its creation stamp, diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index 04fac4c9..3a6dca0c 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -60,7 +60,7 @@ //! which is what a search over `MShotPrice` asks about. use super::verify::archived_replacements; -use super::{Deal, Deltas, EntryParams, Fill, reaches, snap_to_step}; +use super::{Deal, Deltas, EntryParams, Fill, deltas, reaches, snap_to_step}; use crate::feed::types::{Side, Tick}; /// Which price the order keeps its distance from (`MShotUsePrice`). @@ -87,8 +87,19 @@ impl UsePrice { } } -/// The `MShotAdd*` modifiers — per-cent added to the corridor bounds per one per cent of the -/// matching delta at the buy. +/// How a family of modifiers reads the market-wide deltas. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MarketSign { + /// With the sign the report carries — `MShotAddMarketDelta` ("аналогично", FAQ :1289). + #[default] + Signed, + /// As a magnitude — the Delta Modifiers tab's `AddMarketDelta` and `AddMarket24Delta`, "по + /// модулю, то есть всегда положительный" (FAQ :1171, :1172). + Magnitude, +} + +/// A family of delta modifiers — `MShotAdd*` on the entry corridor, the Delta Modifiers tab's +/// `Add*` on the sell and the stop: per cent added per one per cent of the matching delta. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Modifiers { pub add_1m: f64, @@ -105,6 +116,15 @@ pub struct Modifiers { /// and leaves it at zero. pub add_btc_1m: f64, pub add_market_1h: f64, + /// `AddMarket24Delta` of the Delta Modifiers tab (56 live strategies, 2026-09-23); the + /// `MShotAdd*` family has no such field and leaves it at zero. + pub add_market_24h: f64, + /// `AddPump1h` / `AddDump1h` of the Delta Modifiers tab — FAQ :1177, :1178; no live strategy + /// sets them (2026-09-23), read so that one that does is not silently unmodified. + pub add_pump_1h: f64, + pub add_dump_1h: f64, + /// How the family reads the market-wide deltas. + pub market_sign: MarketSign, /// `MShotAddDistance` — per cent by which the far bound's addition exceeds the near one's. pub distance_pct: f64, } @@ -112,8 +132,13 @@ pub struct Modifiers { impl Modifiers { /// The addition to the NEAR bound, in per cent, for these deltas — `Σ k · δ`, every delta /// with its own sign, so a coin that went up gets a deeper order (the module doc has the - /// FAQ example and the live check behind the sign). + /// FAQ example and the live check behind the sign), the market-wide ones as the family reads + /// them ([`MarketSign`]). pub fn near_addition(&self, d: &Deltas) -> f64 { + let market = |delta: f64| match self.market_sign { + MarketSign::Signed => delta, + MarketSign::Magnitude => delta.abs(), + }; self.add_1m * d.d1m + self.add_5m * d.d5m + self.add_15m * d.d15m @@ -124,7 +149,10 @@ impl Modifiers { + self.add_btc_1h * d.btc1h + self.add_btc_5m * d.btc5m + self.add_btc_1m * d.btc1m - + self.add_market_1h * d.market1h + + self.add_market_1h * market(d.market1h) + + self.add_market_24h * market(d.market24h) + + self.add_pump_1h * d.pump1h + + self.add_dump_1h * d.dump1h + self.add_pricebug * d.pricebug } @@ -254,17 +282,18 @@ impl<'a> MshotEntry<'a> { /// /// Args: /// deal: The report row, with its model inputs. - /// far_pct: These parameters' far bound for the deal. + /// created_ms: The order's creation, where both far bounds are read. + /// far_pct: These parameters' far bound for the deal at the creation. /// /// Returns: /// The level, or `None` when the record proves no placement or no reference can be read /// back from it. - fn placement_at_creation(&self, deal: &Deal, far_pct: f64) -> Option { + fn placement_at_creation(&self, deal: &Deal, created_ms: i64, far_pct: f64) -> Option { let fact_level = deal.entry_placed.filter(|l| l.is_finite() && *l > 0.0)?; let Some(EntryParams::MoonShot(own)) = deal.own_entry.as_ref() else { return Some(fact_level); }; - let (_, fact_far_pct) = own.bounds_pct(&deal.deltas); + let (_, fact_far_pct) = own.bounds_pct(&deal.deltas_at(created_ms)); if fact_far_pct == far_pct { return Some(fact_level); } @@ -314,11 +343,7 @@ impl<'a> MshotEntry<'a> { if ticks.is_empty() { return None; } - let (near_pct, far_pct) = self.params.bounds_pct(&deal.deltas); - // The corridor's far edge: a run-away re-places the order only past it (module doc). - // Where the bounds meet after the modifiers (`bounds_pct` lifts far to near) the band has - // no width: every move off the placement re-places, as before. - let retreat_pct = 2.0 * far_pct - near_pct; + let mut bounds = LiveBounds::new(self.params, deal); let raise_wait_ms = (self.params.raise_wait_s * 1000.0).max(0.0); let replace_delay_ms = (self.params.replace_delay_s * 1000.0).max(0.0); let latency_ms = self.params.latency_ms.max(0.0); @@ -333,10 +358,17 @@ impl<'a> MshotEntry<'a> { let mut index = 0; let mut hints: Vec<(i64, f64)> = Vec::new(); // The whole life of the order, when the tape reaches back to its creation. - let created = deal + let created = match deal .order_open_ms() .filter(|&created_ms| first_print_ms <= created_ms) - .and_then(|created_ms| Some((created_ms, self.placement_at_creation(deal, far_pct)?))); + { + Some(created_ms) => { + let (_, far_pct) = bounds.at(created_ms); + self.placement_at_creation(deal, created_ms, far_pct) + .map(|level| (created_ms, level)) + } + None => None, + }; // The exchange's level (what fills; `None` until the order reaches the book) and the // core's (what the corridor is measured against); `pending` is a move the core made that // the exchange has not seen yet. @@ -399,6 +431,7 @@ impl<'a> MshotEntry<'a> { let first = ticks.get(index)?; reference.observe(first); index += 1; + let (_, far_pct) = bounds.at(first.time_ms as i64); self.place(reference.price()?, far_pct, deal) } }; @@ -432,6 +465,11 @@ impl<'a> MshotEntry<'a> { let Some(reference) = reference.price() else { continue; }; + let (near_pct, far_pct) = bounds.at(t_ms); + // The corridor's far edge: a run-away re-places the order only past it (module doc). + // Where the bounds meet after the modifiers (`bounds_pct` lifts far to near) the band + // has no width: every move off the placement re-places, as before. + let retreat_pct = 2.0 * far_pct - near_pct; let distance = Self::distance_pct(reference, core_level, deal); let now = if distance < near_pct { Some(Breach::Approach) @@ -466,6 +504,42 @@ impl<'a> MshotEntry<'a> { } } +/// The corridor's `(near, far)` bounds as the core held them while the order lived: the core +/// moves the corridor when a delta moves ("Дельта меняется — ордер переставляется", FAQ :1289), +/// so the bounds are re-read off the deal's live deltas ([`Deal::deltas_at`]) once per refresh +/// step of the track ([`deltas::STEP_MS`]) — the deltas hold still inside one — and once for a +/// deal without a track, whose deltas are the snapshot throughout. +struct LiveBounds<'a> { + params: &'a MshotParams, + deal: &'a Deal, + /// The step the bounds were last read for. + step: Option, + bounds: (f64, f64), +} + +impl<'a> LiveBounds<'a> { + fn new(params: &'a MshotParams, deal: &'a Deal) -> Self { + Self { + params, + deal, + step: None, + bounds: (0.0, 0.0), + } + } + + fn at(&mut self, t_ms: i64) -> (f64, f64) { + let step = match self.deal.delta_track { + Some(_) => t_ms.div_euclid(deltas::STEP_MS), + None => 0, + }; + if self.step != Some(step) { + self.bounds = self.params.bounds_pct(&self.deal.deltas_at(t_ms)); + self.step = Some(step); + } + self.bounds + } +} + /// The reference price the corridor is measured from, as the prints go by. /// /// The last print of the wanted side (`MShotUsePrice`), falling back to the last print of any diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 0869d348..1d8a12a1 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use super::exit::{ExitParams, UnmodelledRule}; -use super::mshot::{Modifiers, MshotParams, UsePrice}; +use super::mshot::{MarketSign, Modifiers, MshotParams, UsePrice}; /// Which group of the grid a parameter belongs to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -405,6 +405,9 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "AddBTC1mDelta", "AddBTC5mDelta", "AddMarketDelta", + "AddMarket24Delta", + "AddPump1h", + "AddDump1h", ]; /// Every field name the models read — [`TICK_PARAMS`] plus [`MODEL_ONLY_KEYS`] — for a @@ -490,6 +493,11 @@ pub fn mshot_params(v: &StrategyValues<'_>, latency_ms: f64) -> MshotParams { add_btc_1m: 0.0, add_btc_5m: v.num("MShotAddBTC5mDelta", 0.0), add_market_1h: v.num("MShotAddMarketDelta", 0.0), + // The corridor family has none of the three (the exe's `MShotAdd*` list). + add_market_24h: 0.0, + add_pump_1h: 0.0, + add_dump_1h: 0.0, + market_sign: MarketSign::Signed, distance_pct: v.num("MShotAddDistance", 0.0), }, latency_ms, @@ -525,6 +533,10 @@ pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { add_btc_1m: v.num("AddBTC1mDelta", 0.0), add_btc_5m: v.num("AddBTC5mDelta", 0.0), add_market_1h: v.num("AddMarketDelta", 0.0), + add_market_24h: v.num("AddMarket24Delta", 0.0), + add_pump_1h: v.num("AddPump1h", 0.0), + add_dump_1h: v.num("AddDump1h", 0.0), + market_sign: MarketSign::Magnitude, distance_pct: 0.0, }, price_down_timer_s: v.num("PriceDownTimer", base.price_down_timer_s), diff --git a/crates/moon-core/src/db/tuner/ticks/record.rs b/crates/moon-core/src/db/tuner/ticks/record.rs index 94f54cbf..e2d8c126 100644 --- a/crates/moon-core/src/db/tuner/ticks/record.rs +++ b/crates/moon-core/src/db/tuner/ticks/record.rs @@ -54,7 +54,7 @@ impl StopAnchor { /// exit: The sell parameters of the fact. /// exit_points: The archived Exit line, when the archive holds it. pub fn of(deal: &Deal, exit: &ExitParams, exit_points: Option<&[(i64, f64)]>) -> Self { - let pct = stop_pct(exit, deal); + let pct = stop_pct(exit, deal, deal.buy_ms); // The verdict's own test of a stopped fact (`verify::reason_starts_with`), not a copy. let stopped = reason_starts_with(deal.sell_reason.trim(), REASON_STOP); let fired = stopped.then(|| { @@ -87,9 +87,11 @@ impl StopAnchor { /// fill: The walk's entry. /// params: The walk's sell parameters. pub fn holds(&self, deal: &Deal, fill: Fill, params: &ExitParams) -> bool { + // The stop the fact ran is the one its own fill placed: read at the fact's moment, a + // walk filling a few milliseconds off it is still the same stop. fill.price == self.entry_price && (fill.t_ms - self.entry_ms).abs() <= POINT_TIME_TOLERANCE_MS - && stop_pct(params, deal) == self.stop_pct + && stop_pct(params, deal, self.entry_ms) == self.stop_pct && params.stop_loss_delay_s == self.delay_s && params.fast_stop_loss == self.fast && params.stop_loss_ema == self.ema diff --git a/crates/moon-core/src/db/tuner/ticks/record/tests.rs b/crates/moon-core/src/db/tuner/ticks/record/tests.rs index 3605a555..67e2f0f1 100644 --- a/crates/moon-core/src/db/tuner/ticks/record/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -53,6 +53,7 @@ fn stopped() -> Deal { hook_stated_take_pct: None, step_lag_ms: 0.0, stop_anchor: None, + delta_track: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 02027ac1..6b0d1962 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -43,6 +43,7 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { hook_stated_take_pct: None, step_lag_ms: 0.0, stop_anchor: None, + delta_track: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index 74c35c24..61827d3f 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -26,6 +26,7 @@ fn deal(pnl: f64, spent: f64) -> Deal { hook_stated_take_pct: None, step_lag_ms: 0.0, stop_anchor: None, + delta_track: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index f2d61161..2aa7e889 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -28,7 +28,7 @@ fn tape(points: &[(i64, f64)]) -> Vec { points.iter().map(|&(t, p)| tick(t, p, Side::Buy)).collect() } -fn deal() -> Deal { +pub(super) fn deal() -> Deal { Deal { report_uid: 1, core_uid: 7, @@ -53,6 +53,7 @@ fn deal() -> Deal { hook_stated_take_pct: None, step_lag_ms: 0.0, stop_anchor: None, + delta_track: None, own_entry: None, buy_set_ms: None, corridor: None, @@ -488,6 +489,35 @@ fn price_bug_deepens_the_order() { assert!((far - 1.4).abs() < 1e-9, "{far}"); } +#[test] +fn the_sell_family_reads_the_market_as_a_magnitude_and_the_corridor_with_its_sign() { + // FAQ :1171, :1172 — AddMarketDelta / AddMarket24Delta "по модулю"; MShotAddMarketDelta has + // no such word. + let d = Deltas { + market1h: -2.0, + market24h: -3.0, + ..Deltas::default() + }; + let values: HashMap = [ + ("AddMarketDelta", "0.1"), + ("AddMarket24Delta", "0.1"), + ("MShotAddMarketDelta", "0.1"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let defaults = HashMap::new(); + let sv = StrategyValues { + values: &values, + defaults: &defaults, + }; + let sell = exit_params(&sv).sell_mods; + assert!((sell.near_addition(&d) - 0.5).abs() < 1e-9); + let corridor = mshot_params(&sv, DEFAULT_LATENCY_MS).modifiers; + assert!((corridor.near_addition(&d) - -0.2).abs() < 1e-9); + assert!(param_keys().iter().any(|k| k == "AddMarket24Delta")); +} + #[test] fn a_dump_cannot_push_the_bounds_below_the_floor_or_cross_them() { let params = MshotParams { @@ -1287,6 +1317,7 @@ fn hook_deal() -> Deal { hook_stated_take_pct: Some(2.0), step_lag_ms: 0.0, stop_anchor: None, + delta_track: None, own_entry: None, buy_set_ms: None, corridor: None, @@ -1571,25 +1602,25 @@ fn the_stop_modifier_deepens_the_stop_by_the_summed_deltas() { }, ..deal() }; - let pct = moon_core_stop_pct(¶ms, &d); + let pct = moon_core_stop_pct(¶ms, &d, d.buy_ms); assert!((pct - -2.372).abs() < 1e-9, "{pct}"); // No coefficient, no movement; no stop, nothing to move. let off = ExitParams { stop_loss_modifier: 0.0, ..params.clone() }; - assert_eq!(moon_core_stop_pct(&off, &d), -2.0); + assert_eq!(moon_core_stop_pct(&off, &d, d.buy_ms), -2.0); let no_stop = ExitParams { stop_loss_pct: 0.0, ..params.clone() }; - assert_eq!(moon_core_stop_pct(&no_stop, &d), 0.0); + assert_eq!(moon_core_stop_pct(&no_stop, &d, d.buy_ms), 0.0); // `MaxModifier` caps the sum before the coefficient, as it does for the sell. let capped = ExitParams { max_modifier: 1.0, ..params }; - assert!((moon_core_stop_pct(&capped, &d) - -2.2).abs() < 1e-9); + assert!((moon_core_stop_pct(&capped, &d, d.buy_ms) - -2.2).abs() < 1e-9); } /// The adjustment may pull the stop toward the entry — live strategies carry a negative @@ -1614,7 +1645,7 @@ fn an_adjustment_through_the_entry_leaves_no_stop() { }; // −2 − 70·(−0.3) = +19 unguarded: a "stop" nineteen per cent in profit. assert_eq!( - moon_core_stop_pct(&base, &far), + moon_core_stop_pct(&base, &far, far.buy_ms), 0.0, "no stop, not a near one" ); @@ -1631,7 +1662,7 @@ fn an_adjustment_through_the_entry_leaves_no_stop() { }, ..deal() }; - assert_eq!(moon_core_stop_pct(&other, &down), 0.0); + assert_eq!(moon_core_stop_pct(&other, &down, down.buy_ms), 0.0); // A modifier that only moves the stop within its own side is applied as it is. let mild = Deal { deltas: Deltas { @@ -1640,7 +1671,7 @@ fn an_adjustment_through_the_entry_leaves_no_stop() { }, ..deal() }; - assert!((moon_core_stop_pct(&base, &mild) - -1.4).abs() < 1e-9); + assert!((moon_core_stop_pct(&base, &mild, mild.buy_ms) - -1.4).abs() < 1e-9); // A stop the strategy itself put on the profit side stays where it put it — that is its own // setting, not something the adjustment did. let positive = ExitParams { @@ -1655,7 +1686,7 @@ fn an_adjustment_through_the_entry_leaves_no_stop() { }, ..deal() }; - assert!((moon_core_stop_pct(&positive, &up) - 0.4).abs() < 1e-9); + assert!((moon_core_stop_pct(&positive, &up, up.buy_ms) - 0.4).abs() < 1e-9); } /// An adjustment that exactly cancels the stop must not leave one armed at the fill price, @@ -1681,7 +1712,7 @@ fn a_cancelled_stop_does_not_fire_at_the_entry() { }; // The adjustment cancels the stop exactly, so there is none — and the print below the // entry must not read as one. - assert_eq!(moon_core_stop_pct(¶ms, &d), 0.0); + assert_eq!(moon_core_stop_pct(¶ms, &d, d.buy_ms), 0.0); let walk = ExitModel::new(¶ms).walk( &d, // A real move down, not float noise: without the guard the stop sits ON the entry and diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index f2ee8801..450e8007 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -27,6 +27,7 @@ use crate::db::analytics::Query; use crate::db::order_traces::{TraceEntry, read_many}; use crate::db::tuner::strategy_values_at; use crate::feed::report_traces::ArchivedLineKind; +use crate::market::kline_cache::KlineCache; use crate::market::trade_replay::{Coverage, TickQuery, long_position_ms, query_held}; use crate::symbol::{coin_match_key, coin_of_market}; @@ -112,7 +113,7 @@ fn dump_deal( "corridor": deal.corridor, "mshot": match entry { EntryParams::MoonShot(p) => { - let (near, far) = p.bounds_pct(&deal.deltas); + let (near, far) = p.bounds_pct(&deal.deltas_at(deal.buy_ms)); serde_json::json!({ "near": near, "far": far, @@ -231,6 +232,78 @@ fn round3(v: Option) -> Option { v.map(|d| (d * 1000.0).round() / 1000.0) } +/// How closely the live deltas, evaluated WITHOUT the anchor, reproduce the report's own snapshot +/// at the moment it was stamped — the check that the windows are the core's. Per field of +/// `deltas::CoinDeltas`: live answers, exact ones (1e-6 pp), ones within 0.1 pp, the errors. +#[derive(Default)] +struct DeltaFidelity { + tracks: usize, + no_stamp: usize, + n: [usize; 6], + exact: [usize; 6], + close: [usize; 6], + errors: [Vec; 6], +} + +impl DeltaFidelity { + fn observe( + &mut self, + deal: &Deal, + cache: &KlineCache, + exchange: &str, + market: &str, + ticks: &[Tick], + covered: &Coverage, + ) { + let Some(at) = deltas::snapshot_ms(deal) else { + self.no_stamp += 1; + return; + }; + let bars = deltas::read_bars(cache, exchange, market, covered); + let Some(track) = + deltas::DeltaTrack::build(&bars, ticks, covered.spans(), deltas::eval_span(deal), None) + else { + return; + }; + self.tracks += 1; + let Some((est, live)) = track.at(at) else { + return; + }; + let d = &deal.deltas; + let report = [d.d1m, d.d5m, d.d15m, d.d1h, d.d3h, d.d24h]; + let model = [est.d1m, est.d5m, est.d15m, est.d1h, est.d3h, est.d24h]; + for field in 0..6 { + if !live[field] || report[field] == 0.0 { + continue; + } + let err = (model[field] - report[field]).abs(); + self.n[field] += 1; + self.exact[field] += usize::from(err <= 1e-6); + self.close[field] += usize::from(err <= 0.1); + self.errors[field].push(err); + } + } + + fn report(&mut self) { + eprintln!( + "live deltas against the report's snapshot at its stamp (no anchor): {} tracks, {} deals with no stamp", + self.tracks, self.no_stamp + ); + for (field, name) in ["d1m", "d5m", "d15m", "d1h", "d3h", "d24h"] + .iter() + .enumerate() + { + let errors = &mut self.errors[field]; + errors.sort_by(f64::total_cmp); + let median = errors.get(errors.len() / 2).copied().unwrap_or(f64::NAN); + eprintln!( + " {name:5} live {:4} · exact {:4} · within 0.1 pp {:4} · median |err| {median:.4} pp", + self.n[field], self.exact[field], self.close[field] + ); + } + } +} + #[test] #[ignore = "needs a live data root in MOON_TICKS_DATA_DIR"] fn real_data_reproduction() { @@ -284,6 +357,22 @@ fn real_data_reproduction() { let keys = param_keys(); let defaults = HashMap::new(); let core_lags = core_step_lags(&read.deals, &keys, &defaults); + // The live deltas' history bars (`deltas::track_for`), off this data root's kline cache — + // opened on a COPY of the data root: opening prunes past the retention, as the app does. + // `MOON_TICKS_SNAPSHOT_DELTAS=1` runs the model on the report's snapshot, for the A/B. + let snapshot_only = std::env::var_os("MOON_TICKS_SNAPSHOT_DELTAS").is_some(); + let klines = KlineCache::open(paths::klines_db_path()); + eprintln!( + "deltas: {}", + if snapshot_only { + "the report's snapshot" + } else if klines.is_some() { + "live track" + } else { + "no kline cache — snapshot" + } + ); + let mut fidelity = DeltaFidelity::default(); eprintln!("PriceDown step lag per core: {core_lags:?}"); let (mut entry_hits, mut entry_n, mut exit_hits, mut exit_n, mut with_tape) = (0, 0, 0, 0, 0); let mut kinds_seen: HashMap = HashMap::new(); @@ -325,6 +414,7 @@ fn real_data_reproduction() { let spans = window.focus_spans(); let mut ticks: Vec = Vec::new(); let mut covered = Coverage::none(); + let mut address: Option<(String, String)> = None; // The core's own venue only: a coin the tape holds under several exchanges is not one // tape, and the axis reads the deal's own (`RowAddress::exchange_key`). A core the logs // never named is skipped rather than replayed on a mixture. @@ -337,6 +427,9 @@ fn real_data_reproduction() { .filter(|(e, m)| e == venue && coin_match_key(coin_of_market(m)) == coin_key) { let (held, held_covered) = held_ticks(exchange, market, &spans); + if address.is_none() && !held.is_empty() { + address = Some((exchange.clone(), market.clone())); + } ticks.extend(held); for &span in held_covered.spans() { covered.add(span); @@ -367,12 +460,24 @@ fn real_data_reproduction() { defaults: &defaults, }; let entry = if entry_model_for(&deal.kind) { - EntryParams::MoonShot(mshot_params(&sv, DEFAULT_LATENCY_MS)) + // `MOON_TICKS_LATENCY_MS=` replays the entry with another replacement latency. + let latency = std::env::var("MOON_TICKS_LATENCY_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_LATENCY_MS); + EntryParams::MoonShot(mshot_params(&sv, latency)) } else { EntryParams::Fact }; let exit = exit_params(&sv); deal.step_lag_ms = core_lags.get(&deal.core_uid).copied().unwrap_or(0.0); + if let (Some(cache), Some((exchange, market))) = (klines.as_ref(), address.as_ref()) { + fidelity.observe(&deal, cache, exchange, market, &ticks, &covered); + if !snapshot_only { + deal.delta_track = + deltas::track_for(cache, exchange, market, &deal, &ticks, &covered); + } + } prepare_deal( &mut deal, &entry, @@ -386,7 +491,7 @@ fn real_data_reproduction() { // The corridor the core saved against the model's band, `near` … `2 · far − near` off one // reference (`mshot`): the ratio of its edges is the band's width whatever the reference. if let (Some((down, up)), EntryParams::MoonShot(params)) = (deal.corridor, &entry) { - let (near, far) = params.bounds_pct(&deal.deltas); + let (near, far) = params.bounds_pct(&deal.deltas_at(deal.buy_ms)); let (a, b) = (near / 100.0, (2.0 * far - near) / 100.0); let predicted = if deal.is_long() { (1.0 - a) / (1.0 - b) @@ -445,7 +550,7 @@ fn real_data_reproduction() { " held exit {:?} at {:+}ms of close · stop {:.3}% · model pts {}", held.exit.kind, held.exit.t_ms - deal.close_ms, - super::super::exit::stop_pct(&exit, &deal), + super::super::exit::stop_pct(&exit, &deal, deal.buy_ms), held.points.len() ); if let Some(points) = exit_points.as_deref() { @@ -579,6 +684,7 @@ fn real_data_reproduction() { "entry from the order's creation: ✓ {created_hits}/{created_n} · stamped entries {stamped}" ); eprintln!("saved corridors the model's band matches to 0.05 %: {corridor_hits}/{corridor_n}"); + fidelity.report(); let mut unfit: Vec<(String, usize)> = unfit.into_iter().collect(); unfit.sort_by_key(|u| std::cmp::Reverse(u.1)); eprintln!("fit for the search: {fit_n} of {with_tape} · left out: {unfit:?}"); diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 0286ae7f..89841d1c 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -55,8 +55,9 @@ use crate::feed::types::Tick; pub const POINT_TIME_TOLERANCE_MS: i64 = 1_000; /// Tolerance on a STOP's level: the modelled level against the one the core fixed carries -/// `StopLossModifier` over the report's ONE snapshot of the deltas, which the core re-reads live -/// (`exit::modifier_sum`), and the residual sits right there. +/// `StopLossModifier` over deltas the model only partly re-reads live — the coin's ranges where +/// the deal has a track, the BTC, market, mark and price-bug terms as the report's one snapshot +/// (`exit::modifier_sum`) — and the residual sits right there. pub const STOP_PRICE_TOLERANCE: f64 = 0.003; /// How much BETTER than the modelled level the fact's fill may be and still be that level's @@ -219,8 +220,9 @@ pub fn verify( // A stop the core fired and the model, holding a stop of its own, never did — the book // proxy of a non-fast stop can stay short of the level to the tape's end — is a miss of // the stop, whatever the line was doing: not a question about another rule. - let missed_stop = - fact_stopped && closed.kind != ExitKind::Stop && stop_pct(&fact_exit, deal) != 0.0; + let missed_stop = fact_stopped + && closed.kind != ExitKind::Stop + && stop_pct(&fact_exit, deal, deal.buy_ms) != 0.0; let (exit_ok, exit_dev, line_points) = if exit.unmodelled.is_some() { // A rule the model does not have was on: whatever the walk made of the trade is not // an answer about it (see `ExitParams::unmodelled`). @@ -454,8 +456,8 @@ fn is_fill_point(deal: &Deal, exit: &ExitParams, last: (i64, f64), prev: (i64, f /// 183 (2026-09-22). With no level on record, the moment and the line are what is left to judge. /// /// The level tolerance is [`STOP_PRICE_TOLERANCE`] rather than the line's: the modelled level -/// carries `StopLossModifier` over the report's ONE snapshot of the deltas, which the core -/// re-reads live (see `exit::modifier_sum`), and the residual sits right there. +/// carries `StopLossModifier` over deltas the model only partly re-reads live (see +/// `exit::modifier_sum`), and the residual sits right there. /// /// Archived moves from the activation on — the first move past the stop level — are the panic /// sell, not the line the rules moved, and are not held against the model. @@ -473,7 +475,7 @@ fn verify_stop( closed: Exit, exit_points: Option<&[(i64, f64)]>, ) -> (Option, Option, Option<(usize, usize)>) { - let stop = stop_pct(exit, deal); + let stop = stop_pct(exit, deal, deal.buy_ms); let level = if deal.is_long() { deal.buy_price * (1.0 + stop / 100.0) } else { @@ -555,10 +557,10 @@ pub fn stated_stop_level(reason: &str) -> Option { } /// How far a modelled entry may sit from the fact and still be the same order, per cent: the -/// corridor's own width (`MShotPrice − MShotPriceMin` with the trade's modifiers), floored at -/// [`PRICE_TOLERANCE`] — see the module doc. +/// corridor's own width (`MShotPrice − MShotPriceMin` with the trade's modifiers, as they stood +/// at the fill), floored at [`PRICE_TOLERANCE`] — see the module doc. pub fn entry_tolerance_pct(params: &MshotParams, deal: &Deal) -> f64 { - let (near, far) = params.bounds_pct(&deal.deltas); + let (near, far) = params.bounds_pct(&deal.deltas_at(deal.buy_ms)); (far - near).max(PRICE_TOLERANCE * 100.0) } diff --git a/crates/moon-core/src/market/source/read.rs b/crates/moon-core/src/market/source/read.rs index d944b338..3dc15509 100644 --- a/crates/moon-core/src/market/source/read.rs +++ b/crates/moon-core/src/market/source/read.rs @@ -324,6 +324,16 @@ impl MarketDataSource { }) } + /// The ONE open kline cache, or `None` before the terminal supplied its path — for a reader + /// that needs history bars without a replay address (the tuner's live deltas). + pub fn kline_cache(&self) -> Option { + self.inner + .read() + .expect("market source poisoned") + .kline_cache + .clone() + } + /// The catalog-verified market a report row's `coin` names on `core`, or `None`. /// /// Supports both historical formats of the stored value: a base (`M`) and an already complete diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs index dca3cc55..e1d5be76 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -729,7 +729,13 @@ fn serve_cluster( entry_line: None, held: None, }; - replay_row(&mut answer, defaults, lines, row.window.long_position_ms); + replay_row( + &mut answer, + defaults, + lines, + row.window.long_position_ms, + row.replay_address.cache.as_ref(), + ); let mut wait = retry_wait(status, answer.tape).map(|s| Duration::from_secs(u64::from(s))); // Back to the end of the venue's turn, for the next walk to continue from where the // tiles end — see [`continues`]; the ceiling, no gain, or any other word is final. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 1e9d38d2..2cb846de 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -27,12 +27,13 @@ use crate::analytics::refresh::{CatchUpOutcome, report_result_is_stale}; use moon_core::db::ReadFail; use moon_core::db::order_traces::{TraceEntry, read_many}; use moon_core::db::tuner::ticks::{ - Deal, DealsRead, EntryParams, OwnLines, entry_model_for, infer_tick, model_window, params, - prepare_deal, required_spans, verify, + Deal, DealsRead, EntryParams, OwnLines, deltas, entry_model_for, infer_tick, model_window, + params, prepare_deal, required_spans, verify, }; use moon_core::db::tuner::{VarStats, Variant, strategy_current_values, strategy_values_at}; use moon_core::feed::report_traces::ArchivedLineKind; use moon_core::feed::types::Tick; +use moon_core::market::kline_cache::KlineCache; use moon_core::market::trade_replay::venue_caps::trade_route; use moon_core::market::trade_replay::worker::inside_retention; use moon_core::market::trade_replay::{ @@ -273,6 +274,8 @@ impl AnalyticsView { return; } let defaults = self.filter_defaults(cx); + // The kline cache the live deltas read their history bars off (`deltas::track_for`). + let klines = self.backend.read(cx).session.market_source().kline_cache(); self.ticks.tape_reading = true; // The fetch job may answer rows while this stage reads them; the ones it answered after // this instant are read again at the end, or the stage would fold the tape it read @@ -307,7 +310,7 @@ impl AnalyticsView { let lines = traces.remove(&row.deal.report_uid).unwrap_or_default(); let tape = tapes.remove(&row.deal.report_uid); let answered = tape.is_some(); - replay_row_with(row, &defaults, lines, tape); + replay_row_with(row, &defaults, lines, tape, klines.as_ref()); // Said at load, not after a walk: a row the venue cannot serve is not // "missing" — it would only ever come back refused. The fetch job's own // path (`replay_row` after a walk) is NOT given this: its retries and its @@ -338,7 +341,7 @@ impl AnalyticsView { .get(&row.deal.report_uid) .cloned() .unwrap_or_default(); - replay_row(row, &defaults, lines, long_position_ms); + replay_row(row, &defaults, lines, long_position_ms, klines.as_ref()); } } rows @@ -553,28 +556,32 @@ pub(super) fn replay_row( defaults: &HashMap, lines: ArchivedLines, long_position_ms: i64, + klines: Option<&KlineCache>, ) { let tape = row .address .as_ref() .and_then(|address| held_tape(address, &row.deal, long_position_ms)); - replay_row_with(row, defaults, lines, tape); + replay_row_with(row, defaults, lines, tape, klines); } /// Run the model on one row from a tape already asked for. A covered row keeps its tape and /// its archived entry start for the variants; a row without an address is left as it is. /// -/// The model inputs read off the order archive go through `prepare_deal`, the same call the -/// `real_data` bench makes, so what it measures is what this table shows. +/// The model inputs read off the order archive go through `prepare_deal`, and the live deltas +/// through `deltas::track_for` — the same calls the `real_data` bench makes, so what it measures +/// is what this table shows. Without the kline cache the deal keeps the report's snapshot. pub(super) fn replay_row_with( row: &mut DealRow, defaults: &HashMap, lines: ArchivedLines, tape: Option, + klines: Option<&KlineCache>, ) { row.ticks = None; row.entry_line = lines.entry_points.clone(); row.held = None; + row.deal.delta_track = None; let Some(address) = row.address.clone() else { return; }; @@ -620,6 +627,18 @@ pub(super) fn replay_row_with( EntryParams::Fact }; let exit = params::exit_params(&sv); + // The coin's deltas along the window, before the record's inputs: the stop anchor reads the + // stop through them. + row.deal.delta_track = klines.and_then(|cache| { + deltas::track_for( + cache, + &address.exchange_key, + &address.market, + &row.deal, + &ticks, + &covered, + ) + }); // What the core's own record fixes: the ask its take was lifted to, the take as placed, // where the entry order was placed, what the fact proves about the stop, the entry the // trade ran with. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 9c9afb42..6e3814a2 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -29,6 +29,7 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { hook_stated_take_pct: None, step_lag_ms: 0.0, stop_anchor: None, + delta_track: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 9abab6c9..a1c8d106 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -82,8 +82,8 @@ impl DealRow { /// Take everything a replay learned about this row from its answer: the tape's word, the /// verdict, the model inputs derived for the deal (the price step, the archived pre-spike /// ask and take, where the entry order was placed, the core's step lag, what the fact proves - /// about the stop, the entry the trade ran with), the prints, the entry line and the held - /// coverage. + /// about the stop, the entry the trade ran with, the live deltas), the prints, the entry line + /// and the held coverage. /// Every fold of a replay answer goes through here: the variants replay the STORED row /// (`prepared_deals`), so a take lifted to the archive's pre-spike ask in the verdict but /// read off the tape in the variants puts the two on different levels, and a row folded @@ -99,6 +99,7 @@ impl DealRow { self.deal.step_lag_ms = answer.deal.step_lag_ms; self.deal.stop_anchor = answer.deal.stop_anchor; self.deal.own_entry = answer.deal.own_entry; + self.deal.delta_track = answer.deal.delta_track; self.ticks = answer.ticks; self.entry_line = answer.entry_line; self.held = answer.held; From 67cd7a1f7f869731d7eabdbe5ff9ed74905c15af Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 14:19:12 +0200 Subject: [PATCH 23/51] feat(tuner): re-evaluate every delta the history can give, and show how close each comes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- crates/moon-core/src/db/tuner/ticks/deltas.rs | 555 ------------------ .../src/db/tuner/ticks/deltas/btc.rs | 101 ++++ .../src/db/tuner/ticks/deltas/coin.rs | 140 +++++ .../src/db/tuner/ticks/deltas/field.rs | 149 +++++ .../src/db/tuner/ticks/deltas/mod.rs | 442 ++++++++++++++ .../src/db/tuner/ticks/deltas/quality.rs | 88 +++ .../src/db/tuner/ticks/deltas/series.rs | 169 ++++++ .../src/db/tuner/ticks/deltas/tests.rs | 384 ++++++++---- crates/moon-core/src/db/tuner/ticks/mod.rs | 17 +- crates/moon-core/src/db/tuner/ticks/mshot.rs | 6 +- crates/moon-core/src/db/tuner/ticks/params.rs | 4 + .../src/db/tuner/ticks/tests/real_data.rs | 128 ++-- .../analytics/tuner/ticks/delta_summary.rs | 74 +++ .../tuner/ticks/delta_summary/tests.rs | 67 +++ .../src/analytics/tuner/ticks/fetch.rs | 10 + .../src/analytics/tuner/ticks/load.rs | 5 +- .../src/analytics/tuner/ticks/mod.rs | 16 + .../src/analytics/tuner/ticks/state.rs | 3 + locales/analytics.yml | 32 + 19 files changed, 1638 insertions(+), 752 deletions(-) delete mode 100644 crates/moon-core/src/db/tuner/ticks/deltas.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/deltas/btc.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/deltas/coin.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/deltas/field.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/deltas/mod.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/deltas/quality.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/deltas/series.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs diff --git a/crates/moon-core/src/db/tuner/ticks/deltas.rs b/crates/moon-core/src/db/tuner/ticks/deltas.rs deleted file mode 100644 index e840e009..00000000 --- a/crates/moon-core/src/db/tuner/ticks/deltas.rs +++ /dev/null @@ -1,555 +0,0 @@ -//! Live coin deltas — the core's `d1m … d24h` re-evaluated along a trade's window the way the -//! core evaluates them, where the report keeps ONE snapshot per trade. -//! -//! What the core computes (`docs-internal/STRATEGY_FORMULAS/deltas.md`: the FAQ, `data/faqru.tsv` -//! :1052, and moonproto's parity port of the core, `state/history_store/derived.rs`): -//! -//! - every coin delta is a RANGE, `(max / min − 1) · 100` over a window — never negative; -//! - the long ones run over the core's five-minute candles, stamped at their END and kept while -//! younger than the window, so a window reaches up to one candle past its name: d15m, d1h, and -//! "d3h" over candles younger than four hours (the FAQ counts that "3ч55м"; the oldest candle -//! began up to 4h05m ago, and on 52 MoonShot trades the report exceeded the range of 3h55m + one -//! candle five times, of 4h + one candle twice) and "d24h" over twenty-five, both never under -//! d1h; -//! - the short ones, d1m and d5m, run over five-second buckets of trades; -//! - the core refreshes them on those five-second buckets. A value holds from one bucket boundary -//! to the next and is computed over what printed BEFORE the boundary: the MoonShot report's -//! d1m is the range of the prints up to the last completed bucket before the fill (12 exact -//! matches of 12 found, 2026-09-23), never the range at the fill itself. -//! -//! Inputs: the tape where it is held, the one-minute bars the tuner's own candle stage keeps in -//! `klines.sqlite` (six hours before every window it fetched), and the recorder's five-minute bars -//! where no minute bar lies. A window the bars do not reach back for is not live and keeps the -//! report's snapshot — d24h as a rule, whose twenty-five hours the cache seldom holds whole. -//! -//! Anchored to the report: at the moment the report stamped its deltas ([`snapshot_ms`]) the track -//! equals the report exactly, and elsewhere it moves by what the tape and the bars moved. The -//! offset absorbs what those inputs cannot see — the phase of the core's candles, and a core that -//! follows the averaged price instead of raw trades (its `DeltasByTrades` switch off). Without -//! the anchor there is no track ([`track_for`]): evaluated alone over the whole replica -//! (2026-09-23, 1 140 trades) the bars and the tape met the report's snapshot to a median of -//! 0.03 pp on d1h, d3h and d24h but 0.15–0.16 pp on d1m, d5m and d15m, which the modifiers' -//! coefficients multiply into the levels. -//! -//! What is not live: the BTC, market, mark-price and price-bug deltas (the report's snapshot — -//! there is no history of them here), and d5s, Pump1h and Dump1h. - -use std::fmt; -use std::sync::Arc; - -use super::entry::KIND_MOONSHOT; -use super::{Deal, Deltas}; -use crate::feed::types::Tick; -use crate::market::kline_cache::KlineCache; -use crate::market::trade_replay::Coverage; - -/// The core's refresh step of the coin deltas, milliseconds — see the module doc. The track's -/// points sit on multiples of it, so a caller can tell two moments of one value apart by -/// `t_ms.div_euclid(STEP_MS)` alone. -pub const STEP_MS: i64 = 5_000; - -const MINUTE_MS: i64 = 60_000; - -/// How far a window over the core's candles reaches past its name: one candle. -const CANDLE_MS: i64 = 5 * MINUTE_MS; - -/// The longest hole between two bars still read as continuous history. A venue may skip a -/// minute nobody traded in; a hole a candle wide is where the history really stops. -const MAX_BAR_GAP_MS: i64 = CANDLE_MS; - -/// How far before a window the widest delta reaches: "d24h", twenty-five hours of candles plus -/// one. -pub const LOOKBACK_MS: i64 = 25 * 60 * MINUTE_MS + CANDLE_MS; - -/// The coin deltas the track re-evaluates, per cent, as `orders_rep` names them. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct CoinDeltas { - pub d1m: f64, - pub d5m: f64, - pub d15m: f64, - pub d1h: f64, - pub d3h: f64, - pub d24h: f64, -} - -impl CoinDeltas { - fn of(d: &Deltas) -> Self { - Self { - d1m: d.d1m, - d5m: d.d5m, - d15m: d.d15m, - d1h: d.d1h, - d3h: d.d3h, - d24h: d.d24h, - } - } - - fn fields_mut(&mut self) -> [&mut f64; 6] { - [ - &mut self.d1m, - &mut self.d5m, - &mut self.d15m, - &mut self.d1h, - &mut self.d3h, - &mut self.d24h, - ] - } - - fn fields(&self) -> [f64; 6] { - [self.d1m, self.d5m, self.d15m, self.d1h, self.d3h, self.d24h] - } -} - -/// The ranges the core keeps, in the order the evaluation walks them. "d3h" and "d24h" are not -/// windows of their own: each is the wider range, never under d1h. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Window { - M1, - M5, - M15, - H1, - H4, - H25, -} - -const WINDOWS: [Window; 6] = [ - Window::M1, - Window::M5, - Window::M15, - Window::H1, - Window::H4, - Window::H25, -]; - -impl Window { - /// How far back from an evaluation moment the window takes what printed. The short ones are - /// whole five-second buckets, so their reach is their name; a window over the candles - /// reaches one candle further (the module doc; measured on 86 trades, d15m's median error - /// fell from 0.21 pp to 0.02 pp with the extra candle). - fn reach_ms(self) -> i64 { - match self { - Self::M1 => MINUTE_MS, - Self::M5 => 5 * MINUTE_MS, - Self::M15 => 15 * MINUTE_MS + CANDLE_MS, - Self::H1 => 60 * MINUTE_MS + CANDLE_MS, - Self::H4 => 4 * 60 * MINUTE_MS + CANDLE_MS, - Self::H25 => LOOKBACK_MS, - } - } -} - -/// One bar of history: the extremes printed over `[from_ms, to_ms)`. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Bar { - pub from_ms: i64, - pub to_ms: i64, - pub high: f64, - pub low: f64, -} - -/// What printed over a stretch — a bar, or one print — for the sliding windows. -#[derive(Clone, Copy, Debug)] -struct Item { - /// Exclusive end: the item is complete, and joins a window, at a boundary not before it. - end_ms: i64, - high: f64, - low: f64, -} - -/// The track over one covered stretch of the tape. -#[derive(Clone, Debug, PartialEq)] -struct Segment { - /// The first evaluation moment, a multiple of [`STEP_MS`]; point `i` holds from - /// `first_ms + i · STEP_MS` for one step. - first_ms: i64, - values: Vec, - /// Per field of [`CoinDeltas`]: whether the history reached back far enough for it to be - /// live here. A field that is not keeps the report's snapshot. - live: [bool; 6], -} - -impl Segment { - fn point(&self, t_ms: i64) -> Option<&CoinDeltas> { - if t_ms < self.first_ms { - return None; - } - self.values - .get(usize::try_from((t_ms - self.first_ms).div_euclid(STEP_MS)).ok()?) - } -} - -/// The coin deltas of one trade along its window — see the module doc. Built once per trade by -/// the caller that holds its tape ([`track_for`]) and read by the models at every moment they -/// place something ([`Deal::deltas_at`]). -#[derive(Clone, PartialEq)] -pub struct DeltaTrack { - segments: Vec, -} - -impl fmt::Debug for DeltaTrack { - /// A summary: the points themselves are thousands of numbers nobody reads in a log. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let points: usize = self.segments.iter().map(|s| s.values.len()).sum(); - f.debug_struct("DeltaTrack") - .field("segments", &self.segments.len()) - .field("points", &points) - .finish() - } -} - -impl DeltaTrack { - /// Evaluate the coin deltas over every covered stretch of the tape. - /// - /// Args: - /// bars: History bars, ascending by start. A bar that overlaps the tape's coverage is - /// left out — the prints there are the truth, and a bar would carry prints from after - /// the moment it is read at. - /// ticks: The tape, ascending. - /// covered: The spans the tape covers, ascending; the track is evaluated inside them. - /// eval: The stretch the models read deltas over (see [`eval_span`]); the covered spans - /// are clipped to it, which is what bounds the track's size on a wide margin. - /// anchor: The report's snapshot and the moment it was stamped ([`snapshot_ms`]); the - /// track is shifted to equal it there. `None` leaves the evaluation as it is. - /// - /// Returns: - /// The track, or `None` when no field is live anywhere — the caller keeps the snapshot. - pub fn build( - bars: &[Bar], - ticks: &[Tick], - covered: &[(i64, i64)], - eval: (i64, i64), - anchor: Option<(i64, &Deltas)>, - ) -> Option { - let overlaps_tape = |bar: &Bar| { - covered - .iter() - .any(|&(from, to)| bar.from_ms < to && bar.to_ms > from) - }; - let history: Vec = bars - .iter() - .filter(|b| b.low > 0.0 && b.high >= b.low && b.to_ms > b.from_ms && !overlaps_tape(b)) - .copied() - .collect(); - let mut items: Vec = history - .iter() - .map(|b| Item { - end_ms: b.to_ms, - high: b.high, - low: b.low, - }) - .collect(); - items.extend(ticks.iter().filter_map(|t| { - let price = f64::from(t.price); - (price.is_finite() && price > 0.0).then_some(Item { - end_ms: t.time_ms as i64 + 1, - high: price, - low: price, - }) - })); - items.sort_by_key(|i| i.end_ms); - let continuous = continuous_spans(&history, covered); - - let mut windows = WINDOWS.map(|w| Extremes::new(w.reach_ms())); - let mut next = 0usize; - let mut segments = Vec::new(); - for &(span_from, span_to) in covered { - let (from, to) = (span_from.max(eval.0), span_to.min(eval.1)); - let first_ms = ceil_step(from); - if first_ms > to { - continue; - } - // How far the history reaches back unbroken from this stretch. - let history_from = continuous - .iter() - .find(|&&(a, b)| a <= span_from && span_to <= b) - .map_or(span_from, |&(a, _)| a); - let reaches = WINDOWS.map(|w| history_from <= first_ms - w.reach_ms()); - let [m1, m5, m15, h1, h4, h25] = reaches; - let live = [m1, m5, m15, h1, h1 && h4, h1 && h25]; - let mut values = Vec::new(); - let mut at = first_ms; - while at <= to { - while next < items.len() && items[next].end_ms <= at { - for window in &mut windows { - window.push(next, &items); - } - next += 1; - } - let [r1, r5, r15, rh1, rh4, rh25] = - windows.each_mut().map(|w| w.range_at(at, &items)); - values.push(CoinDeltas { - d1m: r1, - d5m: r5, - d15m: r15, - d1h: rh1, - d3h: rh1.max(rh4), - d24h: rh1.max(rh25), - }); - at += STEP_MS; - } - segments.push(Segment { - first_ms, - values, - live, - }); - } - let mut track = Self { segments }; - if let Some((at, snapshot)) = anchor { - // An anchor asked for and not found — a hole in the tape at the stamp — is no track: - // the evaluation alone is not the core's number (see `track_for`). - if !track.anchor(at, &CoinDeltas::of(snapshot)) { - return None; - } - } - track - .segments - .iter() - .any(|s| s.live.iter().any(|&l| l)) - .then_some(track) - } - - /// Shift every live field by what separates the evaluation from the report's snapshot at - /// the moment it was stamped. A field the report holds at exactly zero is not the core's - /// range — no market holds still for an hour — but a field it never filled, and it is not - /// made live: the model keeps the report's zero, as it did before the track. - /// - /// Returns whether the track reaches the stamp at all; nothing is shifted when it does not. - /// A field is left live only where it was live AT the stamp as well: an offset read off a - /// field the history did not reach there is no offset. - fn anchor(&mut self, at: i64, snapshot: &CoinDeltas) -> bool { - let Some((estimate, live_at_stamp)) = self.at(at) else { - return false; - }; - let snap = snapshot.fields(); - let est = estimate.fields(); - for segment in &mut self.segments { - for (field, live) in segment.live.iter_mut().enumerate() { - *live = *live && live_at_stamp[field] && snap[field] != 0.0; - } - for value in &mut segment.values { - for (field, slot) in value.fields_mut().into_iter().enumerate() { - *slot = (*slot + snap[field] - est[field]).max(0.0); - } - } - } - true - } - - /// The deltas at a moment: the snapshot with every field this track has live there - /// replaced. Outside the covered stretches, the snapshot as it is. - pub fn apply(&self, t_ms: i64, snapshot: &Deltas) -> Deltas { - let Some((values, live)) = self.at(t_ms) else { - return *snapshot; - }; - let pick = |field: usize, own: f64, snap: f64| if live[field] { own } else { snap }; - Deltas { - d1m: pick(0, values.d1m, snapshot.d1m), - d5m: pick(1, values.d5m, snapshot.d5m), - d15m: pick(2, values.d15m, snapshot.d15m), - d1h: pick(3, values.d1h, snapshot.d1h), - d3h: pick(4, values.d3h, snapshot.d3h), - d24h: pick(5, values.d24h, snapshot.d24h), - ..*snapshot - } - } - - /// The evaluated coin deltas at a moment and which of them are live there, or `None` - /// outside the covered stretches. - pub fn at(&self, t_ms: i64) -> Option<(CoinDeltas, [bool; 6])> { - self.segments - .iter() - .find_map(|s| s.point(t_ms).map(|v| (*v, s.live))) - } -} - -/// The sliding extremes of one window: two monotone queues of item indices, the highs -/// decreasing and the lows increasing from the front, so the front of each is the extreme of -/// what the window holds. -struct Extremes { - reach_ms: i64, - highs: std::collections::VecDeque, - lows: std::collections::VecDeque, -} - -impl Extremes { - fn new(reach_ms: i64) -> Self { - Self { - reach_ms, - highs: std::collections::VecDeque::new(), - lows: std::collections::VecDeque::new(), - } - } - - fn push(&mut self, index: usize, items: &[Item]) { - let item = items[index]; - while self - .highs - .back() - .is_some_and(|&i| items[i].high <= item.high) - { - self.highs.pop_back(); - } - self.highs.push_back(index); - while self.lows.back().is_some_and(|&i| items[i].low >= item.low) { - self.lows.pop_back(); - } - self.lows.push_back(index); - } - - /// The range at a boundary, per cent: what ended inside `(at − reach, at]` — a bar that - /// began before the window's start but ended inside it counts whole, as the core's candle - /// does. Zero when the window holds nothing. - fn range_at(&mut self, at: i64, items: &[Item]) -> f64 { - let start = at - self.reach_ms; - while self - .highs - .front() - .is_some_and(|&i| items[i].end_ms <= start) - { - self.highs.pop_front(); - } - while self.lows.front().is_some_and(|&i| items[i].end_ms <= start) { - self.lows.pop_front(); - } - match (self.highs.front(), self.lows.front()) { - (Some(&h), Some(&l)) if items[l].low > 0.0 => { - (items[h].high / items[l].low - 1.0) * 100.0 - } - _ => 0.0, - } - } -} - -/// The stretches of time the history covers without a break: the bars joined across holes up -/// to [`MAX_BAR_GAP_MS`], and the tape's own coverage. -fn continuous_spans(bars: &[Bar], covered: &[(i64, i64)]) -> Vec<(i64, i64)> { - let mut spans: Vec<(i64, i64)> = bars - .iter() - .map(|b| (b.from_ms, b.to_ms)) - .chain(covered.iter().copied()) - .collect(); - spans.sort_unstable(); - let mut out: Vec<(i64, i64)> = Vec::new(); - for (from, to) in spans { - match out.last_mut() { - Some(last) if from <= last.1 + MAX_BAR_GAP_MS => last.1 = last.1.max(to), - _ => out.push((from, to)), - } - } - out -} - -/// The first multiple of [`STEP_MS`] at or after a moment. -fn ceil_step(t_ms: i64) -> i64 { - t_ms.div_euclid(STEP_MS) * STEP_MS - + if t_ms.rem_euclid(STEP_MS) == 0 { - 0 - } else { - STEP_MS - } -} - -/// Where the models read a deal's deltas: from the run-up before its entry order's life began — -/// the creation, or the buy where the report stamps no creation the tape reaches — through the -/// close. An entry model placing its order off an earlier print, or a variant filling after the -/// close, reads the snapshot there. -pub fn eval_span(deal: &Deal) -> (i64, i64) { - let open = deal.order_open_ms().unwrap_or(deal.buy_ms); - (open - super::RUN_UP_MS, deal.close_ms) -} - -/// When the report stamped a trade's deltas (FAQ :423): at the buy for MoonShot, at the detect -/// and the placement of the buy order for every other kind — its creation stamp, where the -/// report has one. `None` for a creation the report does not stamp. -pub fn snapshot_ms(deal: &Deal) -> Option { - if deal.kind == KIND_MOONSHOT { - Some(deal.buy_ms) - } else { - deal.buy_set_ms - } -} - -/// The history bars a track over `covered` reads off the kline cache: the one-minute bars, and -/// the five-minute ones wherever no minute bar lies. A read the cache did not answer in time is -/// an empty history, and the windows it would have fed stay on the snapshot. -/// -/// Args: -/// cache: The terminal's kline cache. -/// exchange_key: The cache's exchange key of the deal's core (`"{code}:{dex:08x}"`). -/// market: The market as the core spells it. -/// covered: The tape's coverage the track is evaluated over. -pub fn read_bars( - cache: &KlineCache, - exchange_key: &str, - market: &str, - covered: &Coverage, -) -> Vec { - let Some((from, to)) = covered.hull() else { - return Vec::new(); - }; - let from = from - LOOKBACK_MS - CANDLE_MS; - let read = |kind_min: u32| { - let span_ms = i64::from(kind_min) * MINUTE_MS; - cache - .read_range(exchange_key, market, kind_min, from, to) - .unwrap_or_default() - .into_iter() - .filter(|c| c.t_open_ms.is_finite()) - .map(|c| { - let from_ms = c.t_open_ms as i64; - Bar { - from_ms, - to_ms: from_ms + span_ms, - high: f64::from(c.high), - low: f64::from(c.low), - } - }) - .collect::>() - }; - let minutes = read(1); - let mut bars = minutes.clone(); - bars.extend(read(5).into_iter().filter(|five| { - !minutes - .iter() - .any(|m| m.from_ms >= five.from_ms && m.from_ms < five.to_ms) - })); - bars.sort_by_key(|b| b.from_ms); - bars -} - -/// The track of one deal, from its tape and the kline cache — the one call the tuner's table and -/// the `real_data` bench both make, so what the bench measures is what the table replays. -/// -/// Args: -/// cache: The terminal's kline cache. -/// exchange_key: The cache's exchange key of the deal's core. -/// market: The market as the core spells it. -/// deal: The trade, for its snapshot and the moment it was stamped. -/// ticks: The tape, ascending. -/// covered: The tape's coverage. -pub fn track_for( - cache: &KlineCache, - exchange_key: &str, - market: &str, - deal: &Deal, - ticks: &[Tick], - covered: &Coverage, -) -> Option> { - // Only a track anchored on the report is the core's number: evaluated alone, it placed 89 - // MoonHook takes further from the core's than the snapshot did (median 0.19 pp against - // 0.09, 2026-09-23), while anchored at the order's creation it placed the 21 stamped ones - // closer (11 against 7). A trade without a stamp the tape reaches keeps the snapshot. - let at = snapshot_ms(deal)?; - let bars = read_bars(cache, exchange_key, market, covered); - DeltaTrack::build( - &bars, - ticks, - covered.spans(), - eval_span(deal), - Some((at, &deal.deltas)), - ) - .map(Arc::new) -} - -#[cfg(test)] -mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/btc.rs b/crates/moon-core/src/db/tuner/ticks/deltas/btc.rs new file mode 100644 index 00000000..9cd45e0e --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/btc.rs @@ -0,0 +1,101 @@ +//! BTC's deltas, read off the BTC market of the deal's own exchange. +//! +//! What the core computes (FAQ :1068, :1073, :1074; moonproto `state/markets/prices.rs`): +//! +//! - `btc5mdelta`, `dbtc1m`: BTC's range over the last five minutes and the last minute, +//! `(max / min − 1) · 100`; +//! - `btc1hdelta`: signed — how far BTC's price stands from its one-hour average, the average an +//! exponential one stepped every thirty seconds with weight 0.01 (moonproto: `avg = p · 0.01 + +//! avg · 0.99`), `(price − average) / average · 100`. +//! +//! The history is whatever bars the kline cache holds for that market — the minute bars where +//! something fetched them, the recorder's five-minute ones elsewhere. A window narrower than the +//! bar it is read off takes that bar whole, so on five-minute bars `dbtc1m` is a bar's range, not +//! a minute's: the summary says how much of each window the history covered. + +use super::MINUTE_MS; +use super::field::DeltaField; +use super::series::{Extremes, Series}; + +/// The step the core moves BTC's average by (moonproto: at most every 30 s). +const AVERAGE_STEP_MS: i64 = 30_000; + +/// What the average keeps of itself at each step. +const AVERAGE_KEEP: f64 = 0.99; + +/// The oldest BTC print the average may stand on, relative to the boundary: past it the bars +/// have stopped and the price is stale. +const STALE_MS: i64 = 60 * MINUTE_MS; + +/// BTC's fields, in the order [`BtcPoint`] carries them. +pub(super) const FIELDS: [DeltaField; 3] = + [DeltaField::Btc1m, DeltaField::Btc5m, DeltaField::Btc1h]; + +/// BTC's fields at one boundary: the value, `None` when the history has nothing for it, and the +/// share of the window the history covers. +pub(super) type BtcPoint = [(Option, f64); 3]; + +/// Walks BTC's bars boundary by boundary, ascending. +pub(super) struct BtcEval<'a> { + series: &'a Series, + windows: [Extremes; 2], + next: usize, + average: Option, + /// The last complete bar's close, its end, and its length. + last: Option<(f64, i64, i64)>, +} + +impl<'a> BtcEval<'a> { + pub(super) fn new(series: &'a Series) -> Self { + Self { + series, + windows: [Extremes::new(), Extremes::new()], + next: 0, + average: None, + last: None, + } + } + + /// BTC's fields at a boundary, over the bars complete before it. Called at ascending + /// boundaries. + pub(super) fn at(&mut self, at: i64) -> BtcPoint { + let series: &'a Series = self.series; + let items = &series.items; + while self.next < items.len() && items[self.next].end_ms <= at { + let item = items[self.next]; + for window in &mut self.windows { + window.push(self.next, items); + } + let length = (item.end_ms - item.from_ms).max(1); + self.average = Some(match self.average { + // Seeded as the core seeds it, off a candle's mean price. + None => (item.open + item.high + item.low + item.close) / 4.0, + Some(average) => { + let steps = (length / AVERAGE_STEP_MS).max(1) as i32; + let keep = AVERAGE_KEEP.powi(steps); + item.close * (1.0 - keep) + average * keep + } + }); + self.last = Some((item.close, item.end_ms, length)); + self.next += 1; + } + let fresh = self.last.filter(|&(_, end, _)| end > at - STALE_MS); + let bar = fresh.map_or(0, |(_, _, length)| length); + let reach_1m = MINUTE_MS.max(bar); + let reach_5m = (5 * MINUTE_MS).max(bar); + let btc1m = fresh.and_then(|_| self.windows[0].range_at(at, reach_1m, items)); + let btc5m = fresh.and_then(|_| self.windows[1].range_at(at, reach_5m, items)); + let btc1h = match (fresh, self.average) { + (Some((price, _, _)), Some(average)) if average > 0.0 => { + Some((price - average) / average * 100.0) + } + _ => None, + }; + let cover = |reach: i64| series.covered_fraction(at - reach, at); + [ + (btc1m, cover(reach_1m)), + (btc5m, cover(reach_5m)), + (btc1h, cover(STALE_MS)), + ] + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/coin.rs b/crates/moon-core/src/db/tuner/ticks/deltas/coin.rs new file mode 100644 index 00000000..9f6035be --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/coin.rs @@ -0,0 +1,140 @@ +//! The deal's own coin: its ranges over the core's windows, the last five seconds' move, and the +//! hour's pump and dump. +//! +//! The ranges (`d1m … d24h`) are the core's — see the module doc of [`super`]. The other three +//! are defined by the FAQ in words only and checked on 91 Binance trades (2026-09-23): +//! +//! - Pump1h (FAQ :1047 "the difference between the price an hour ago and the hour's high"): +//! `(high / price an hour ago − 1) · 100` met the report exactly on 3 trades, median error +//! 0.24 pp — the core's "an hour ago" is a moment of its own; +//! - Dump1h ("… and the hour's low"): `(price an hour ago − low) / price an hour ago · 100`, +//! exactly on 5, median 0.15 pp; +//! - d5s (no definition anywhere; RTTI `Last5sDelta`): the move over the last five-second +//! bucket, `|last price / the last price a bucket earlier − 1| · 100`, median 0.07 pp — closer +//! than the bucket's range (0.10 pp). +//! +//! The anchor on the report (see [`super::DeltaTrack`]) takes the offset out; what these carry +//! into the model is how they move. + +use super::field::DeltaField; +use super::series::{Extremes, Series}; +use super::{CANDLE_MS, LOOKBACK_MS, MINUTE_MS, STEP_MS}; + +/// How far back each window the coin's fields are read over reaches: the short ones are whole +/// five-second buckets, so their reach is their name; a window over the core's candles reaches +/// one candle further (measured on 86 trades, d15m's median error fell from 0.21 pp to 0.02 pp +/// with the extra candle). The last one is the plain hour Pump1h and Dump1h look back over. +const REACH: [i64; 7] = [ + MINUTE_MS, + 5 * MINUTE_MS, + 15 * MINUTE_MS + CANDLE_MS, + 60 * MINUTE_MS + CANDLE_MS, + 4 * 60 * MINUTE_MS + CANDLE_MS, + LOOKBACK_MS, + 60 * MINUTE_MS, +]; +const M1: usize = 0; +const M5: usize = 1; +const M15: usize = 2; +const H1: usize = 3; +const H4: usize = 4; +const H25: usize = 5; +const HOUR: usize = 6; + +/// The coin's fields at one boundary: the value, `None` when the window holds nothing, and the +/// share of the window the history covers. +pub(super) type CoinPoint = [(Option, f64); 9]; + +/// The coin's fields, in the order [`CoinPoint`] carries them. +pub(super) const FIELDS: [DeltaField; 9] = [ + DeltaField::D1m, + DeltaField::D5m, + DeltaField::D15m, + DeltaField::D1h, + DeltaField::D3h, + DeltaField::D24h, + DeltaField::D5s, + DeltaField::Pump1h, + DeltaField::Dump1h, +]; + +/// Walks the coin's history boundary by boundary, ascending. +pub(super) struct CoinEval<'a> { + series: &'a Series, + windows: [Extremes; 7], + /// The next item to enter the windows. + next: usize, + /// The earliest item still inside the plain hour — its open is "the price an hour ago". + hour_first: usize, + /// The last price before the previous boundary, and that boundary. + prev: Option<(i64, f64)>, +} + +impl<'a> CoinEval<'a> { + pub(super) fn new(series: &'a Series) -> Self { + Self { + series, + windows: std::array::from_fn(|_| Extremes::new()), + next: 0, + hour_first: 0, + prev: None, + } + } + + /// The coin's fields at a boundary, over what printed before it. Called at ascending + /// boundaries; d5s answers only where the previous call was one step earlier. + pub(super) fn at(&mut self, at: i64) -> CoinPoint { + let series: &'a Series = self.series; + let items = &series.items; + while self.next < items.len() && items[self.next].end_ms <= at { + for window in &mut self.windows { + window.push(self.next, items); + } + self.next += 1; + } + let ranges: [Option; 6] = + std::array::from_fn(|w| self.windows[w].range_at(at, REACH[w], items)); + let long = |wide: Option| match (ranges[H1], wide) { + (Some(h1), Some(w)) => Some(h1.max(w)), + (h1, w) => h1.or(w), + }; + // The last price before this boundary against the one before the previous. + let last = self.next.checked_sub(1).map(|i| items[i].close); + let d5s = match (self.prev, last) { + (Some((was, before)), Some(now)) if was == at - STEP_MS && before > 0.0 => { + Some((now / before - 1.0).abs() * 100.0) + } + _ => None, + }; + if let Some(now) = last { + self.prev = Some((at, now)); + } + // Pump and dump over the plain hour, off the price it began at. + let hour_start = at - REACH[HOUR]; + while self.hour_first < self.next && items[self.hour_first].end_ms <= hour_start { + self.hour_first += 1; + } + let (pump, dump) = match ( + self.windows[HOUR].extremes_at(at, REACH[HOUR], items), + (self.hour_first < self.next).then(|| items[self.hour_first].open), + ) { + (Some((high, low)), Some(open)) if open > 0.0 => ( + Some(((high / open - 1.0) * 100.0).max(0.0)), + Some(((open - low) / open * 100.0).max(0.0)), + ), + _ => (None, None), + }; + let cover = |reach: i64| series.covered_fraction(at - reach, at); + [ + (ranges[M1], cover(REACH[M1])), + (ranges[M5], cover(REACH[M5])), + (ranges[M15], cover(REACH[M15])), + (ranges[H1], cover(REACH[H1])), + (long(ranges[H4]), cover(REACH[H4])), + (long(ranges[H25]), cover(REACH[H25])), + (d5s, cover(STEP_MS)), + (pump, cover(REACH[HOUR])), + (dump, cover(REACH[HOUR])), + ] + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/field.rs b/crates/moon-core/src/db/tuner/ticks/deltas/field.rs new file mode 100644 index 00000000..a9382c16 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/field.rs @@ -0,0 +1,149 @@ +//! Which of the report's deltas the track re-evaluates, and which it cannot. + +use super::super::Deltas; + +/// A delta the track re-evaluates along the window. Every one is a report column +/// ([`DeltaField::column`]) and a modifier input of one family or both — `MShotAdd*` on the +/// MoonShot corridor, `Add*` on the sell and the stop (`mshot::Modifiers`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DeltaField { + D1m, + D5m, + D15m, + D1h, + D3h, + D24h, + /// The last five seconds' move (`d5s`, RTTI `Last5sDelta`). + D5s, + /// The hour's rise from the price an hour ago to its high. + Pump1h, + /// The hour's fall from the price an hour ago to its low. + Dump1h, + /// BTC's 1-minute range (`dbtc1m`). + Btc1m, + /// BTC's 5-minute range (`btc5mdelta`). + Btc5m, + /// BTC's signed deviation from its 1-hour average (`btc1hdelta`). + Btc1h, +} + +impl DeltaField { + /// Every field, in the order the track stores them. + pub const ALL: [Self; 12] = [ + Self::D1m, + Self::D5m, + Self::D15m, + Self::D1h, + Self::D3h, + Self::D24h, + Self::D5s, + Self::Pump1h, + Self::Dump1h, + Self::Btc1m, + Self::Btc5m, + Self::Btc1h, + ]; + + pub const COUNT: usize = Self::ALL.len(); + + /// The field's slot in a track's values. + pub fn index(self) -> usize { + self as usize + } + + /// The report column the field is the snapshot of. + pub fn column(self) -> &'static str { + match self { + Self::D1m => "d1m", + Self::D5m => "d5m", + Self::D15m => "d15m", + Self::D1h => "d1h", + Self::D3h => "d3h", + Self::D24h => "d24h", + Self::D5s => "d5s", + Self::Pump1h => "pump1h", + Self::Dump1h => "dump1h", + Self::Btc1m => "dbtc1m", + Self::Btc5m => "btc5mdelta", + Self::Btc1h => "btc1hdelta", + } + } + + /// The field's value in a set of deltas. + pub fn of(self, d: &Deltas) -> f64 { + match self { + Self::D1m => d.d1m, + Self::D5m => d.d5m, + Self::D15m => d.d15m, + Self::D1h => d.d1h, + Self::D3h => d.d3h, + Self::D24h => d.d24h, + Self::D5s => d.d5s, + Self::Pump1h => d.pump1h, + Self::Dump1h => d.dump1h, + Self::Btc1m => d.btc1m, + Self::Btc5m => d.btc5m, + Self::Btc1h => d.btc1h, + } + } + + /// Replace the field's value in a set of deltas. + pub fn set(self, d: &mut Deltas, value: f64) { + let slot = match self { + Self::D1m => &mut d.d1m, + Self::D5m => &mut d.d5m, + Self::D15m => &mut d.d15m, + Self::D1h => &mut d.d1h, + Self::D3h => &mut d.d3h, + Self::D24h => &mut d.d24h, + Self::D5s => &mut d.d5s, + Self::Pump1h => &mut d.pump1h, + Self::Dump1h => &mut d.dump1h, + Self::Btc1m => &mut d.btc1m, + Self::Btc5m => &mut d.btc5m, + Self::Btc1h => &mut d.btc1h, + }; + *slot = value; + } + + /// Whether a report value of exactly zero means the core never filled the field rather than + /// a market that held still: true for the coin's ranges of a minute and longer — no traded + /// market prints one price for a minute on end — and for every BTC field: BTC never holds + /// still for a minute, and a deviation from its average is never exactly zero, while a + /// report column the replica lacks reads as zero (`deals::read_on`), and a core without BTC's + /// prices files zeros (a MoonShot trade of 2026-06-25 on a local core: all BTC deltas 0). + /// Five seconds and an hour's rise or fall can legitimately be zero. + pub fn zero_is_unfilled(self) -> bool { + !matches!(self, Self::D5s | Self::Pump1h | Self::Dump1h) + } + + /// Whether the field is read off BTC's market rather than the deal's own. + pub fn is_btc(self) -> bool { + matches!(self, Self::Btc1m | Self::Btc5m | Self::Btc1h) + } +} + +/// A report delta the track does not re-evaluate, and why — the summary says so beside the +/// fields it does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NotComputed { + /// `dmark`: the mark price has no history here. + MarkPrice, + /// `pricebug`: the core's own lag measure against its price line. + PriceBug, + /// `exchange1hdelta`, `exchange24hdelta`: an average over every market of the exchange. + Market, +} + +impl NotComputed { + pub const ALL: [Self; 3] = [Self::MarkPrice, Self::PriceBug, Self::Market]; + + /// The report columns this entry stands for. + pub fn columns(self) -> &'static str { + match self { + Self::MarkPrice => "dmark", + Self::PriceBug => "pricebug", + Self::Market => "exchange1hdelta, exchange24hdelta", + } + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs b/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs new file mode 100644 index 00000000..1110c0fa --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs @@ -0,0 +1,442 @@ +//! Live deltas — the report's deltas re-evaluated along a trade's window the way the core +//! evaluates them, where the report keeps ONE snapshot per trade. One place computes them for +//! every consumer: MoonShot's `MShotAdd*` corridor and every kind's `Add*` sell and stop read the +//! same values through [`Deal::deltas_at`]. +//! +//! What the core computes (`docs-internal/STRATEGY_FORMULAS/deltas.md`: the FAQ, `data/faqru.tsv` +//! :1052, :1068, :1073, and moonproto's parity port of the core, `state/history_store/derived.rs` +//! and `state/markets/prices.rs`): +//! +//! - every coin delta is a RANGE, `(max / min − 1) · 100` over a window — never negative; +//! - the long ones run over the core's five-minute candles, stamped at their END and kept while +//! younger than the window, so a window reaches up to one candle past its name: d15m, d1h, and +//! "d3h" over candles younger than four hours (the FAQ counts that "3ч55м"; the oldest candle +//! began up to 4h05m ago, and on 52 MoonShot trades the report exceeded the range of 3h55m + one +//! candle five times, of 4h + one candle twice) and "d24h" over twenty-five, both never under +//! d1h; +//! - the short ones, d1m and d5m, run over five-second buckets of trades; +//! - the core refreshes them on those five-second buckets. A value holds from one bucket boundary +//! to the next and is computed over what printed BEFORE the boundary: the MoonShot report's +//! d1m is the range of the prints up to the last completed bucket before the fill (12 exact +//! matches of 12 found, 2026-09-23), never the range at the fill itself; +//! - d5s, Pump1h and Dump1h ([`coin`]) and the BTC deltas ([`btc`]) have their own rules. +//! +//! Inputs: the tape where it is held, and whatever bars the kline cache holds — the minute bars +//! the tuner's candle stage keeps (six hours before every window it fetched), the recorder's +//! five-minute ones wherever no minute bar lies, for the deal's market and for BTC's on the same +//! exchange. A window is read over what history it has, however much that is — twenty-three +//! hours of a twenty-five-hour window, or its first and last bars across a hole — and the share +//! it covered is kept for the summary ([`StampCheck`], [`quality`]). +//! +//! Anchored to the report: at the moment the report stamped its deltas ([`snapshot_ms`]) the track +//! equals the report exactly, and elsewhere it moves by what the tape and the bars moved. The +//! offset absorbs what those inputs cannot see — the phase of the core's candles, the holes in the +//! history, a core that follows the averaged price instead of raw trades (its `DeltasByTrades` +//! switch off). Without the anchor there is no track ([`track_for`]): evaluated alone over the +//! whole replica (2026-09-23, 1 140 trades) the bars and the tape met the report's snapshot to a +//! median of 0.03 pp on d1h, d3h and d24h but 0.15–0.16 pp on d1m, d5m and d15m, and placed 89 +//! MoonHook takes further from the core's than the snapshot did (median 0.19 pp against 0.09). +//! +//! What is not re-evaluated, and stays the snapshot: the mark-price, price-bug and market-wide +//! deltas ([`NotComputed`]). + +use std::fmt; +use std::sync::Arc; + +use super::entry::KIND_MOONSHOT; +use super::{Deal, Deltas}; +use crate::feed::types::Tick; +use crate::market::kline_cache::KlineCache; +use crate::market::trade_replay::Coverage; + +mod btc; +mod coin; +pub mod field; +pub mod quality; +mod series; + +pub use field::{DeltaField, NotComputed}; +pub use quality::{DeltaQuality, FieldQuality, summarize}; + +/// The core's refresh step of the deltas, milliseconds — see the module doc. The track's points +/// sit on multiples of it, so a caller can tell two moments of one value apart by +/// `t_ms.div_euclid(STEP_MS)` alone. +pub const STEP_MS: i64 = 5_000; + +const MINUTE_MS: i64 = 60_000; + +/// How far a window over the core's candles reaches past its name: one candle. +const CANDLE_MS: i64 = 5 * MINUTE_MS; + +/// How far before a window the widest coin delta reaches: "d24h", twenty-five hours of candles +/// plus one. +pub const LOOKBACK_MS: i64 = 25 * 60 * MINUTE_MS + CANDLE_MS; + +/// How far before a window BTC's history is read: the hour average forgets a price in about +/// fifty minutes (weight 0.01 every thirty seconds), and four hours of it leave nothing of the +/// seed. +const BTC_LOOKBACK_MS: i64 = 4 * 60 * MINUTE_MS; + +/// One bar of history: what printed over `[from_ms, to_ms)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bar { + pub from_ms: i64, + pub to_ms: i64, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, +} + +/// Every field at one point, in [`DeltaField::ALL`]'s order; `NaN` where the history had nothing. +type Values = [f64; DeltaField::COUNT]; + +/// The track over one covered stretch of the tape. +#[derive(Clone, Debug, PartialEq)] +struct Segment { + /// The first evaluation moment, a multiple of [`STEP_MS`]; point `i` holds from + /// `first_ms + i · STEP_MS` for one step. + first_ms: i64, + values: Vec, +} + +impl Segment { + fn point(&self, t_ms: i64) -> Option<&Values> { + if t_ms < self.first_ms { + return None; + } + self.values + .get(usize::try_from((t_ms - self.first_ms).div_euclid(STEP_MS)).ok()?) + } +} + +/// What the evaluation found at the report's stamp, per field of [`DeltaField::ALL`], BEFORE the +/// anchor moved it onto the report — the measure of how well the history reproduces the core. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct StampCheck { + /// The share of each field's window the history covered at the stamp, 0 … 1. + pub coverage: [f64; DeltaField::COUNT], + /// `|evaluation − report|` at the stamp, per cent points; `None` where the history had + /// nothing, or the report never filled the field. + pub error: [Option; DeltaField::COUNT], +} + +/// The deltas of one trade along its window — see the module doc. Built once per trade by the +/// caller that holds its tape ([`track_for`]) and read by the models at every moment they place +/// something ([`Deal::deltas_at`]). +#[derive(Clone, PartialEq)] +pub struct DeltaTrack { + segments: Vec, + /// Per field: whether the track answers for it. One that does not keeps the snapshot. + live: [bool; DeltaField::COUNT], + stamp: StampCheck, +} + +impl fmt::Debug for DeltaTrack { + /// A summary: the points themselves are thousands of numbers nobody reads in a log. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let points: usize = self.segments.iter().map(|s| s.values.len()).sum(); + f.debug_struct("DeltaTrack") + .field("segments", &self.segments.len()) + .field("points", &points) + .field("live", &self.live) + .finish() + } +} + +/// What a track is built from. +#[derive(Clone, Copy, Debug)] +pub struct TrackInputs<'a> { + /// The deal's market's bars, ascending. One overlapping the tape's coverage is left out — the + /// prints there are the truth, and a bar would carry prints from after the moment it is read + /// at. + pub coin_bars: &'a [Bar], + /// The tape, ascending. + pub ticks: &'a [Tick], + /// The spans the tape covers, ascending; the track is evaluated inside them. + pub covered: &'a [(i64, i64)], + /// The bars of BTC's market on the same exchange, ascending; empty leaves the BTC fields on + /// the snapshot. + pub btc_bars: &'a [Bar], + /// The stretch the models read deltas over ([`eval_span`]); the covered spans are clipped to + /// it, which is what bounds the track's size on a wide margin. + pub eval: (i64, i64), + /// The report's snapshot and the moment it was stamped ([`snapshot_ms`]); the track is + /// shifted to equal it there. `None` leaves the evaluation as it is — for the tests of the + /// windows themselves. + pub anchor: Option<(i64, &'a Deltas)>, +} + +impl DeltaTrack { + /// Evaluate every field over every covered stretch of the tape, and anchor it. + /// + /// Returns: + /// The track, or `None` when an anchor was asked for and the track does not reach its + /// moment — the evaluation alone is not the core's number — or when no field answers. + pub fn build(inputs: TrackInputs<'_>) -> Option { + let coin_series = series::Series::new(inputs.coin_bars, inputs.ticks, inputs.covered); + let btc_series = series::Series::new(inputs.btc_bars, &[], &[]); + let mut coin_eval = coin::CoinEval::new(&coin_series); + let mut btc_eval = btc::BtcEval::new(&btc_series); + let stamp_at = inputs + .anchor + .map(|(at, _)| at.div_euclid(STEP_MS) * STEP_MS); + let mut at_stamp: Option<(Values, [f64; DeltaField::COUNT])> = None; + let mut segments = Vec::new(); + for &(span_from, span_to) in inputs.covered { + let (from, to) = (span_from.max(inputs.eval.0), span_to.min(inputs.eval.1)); + let first_ms = ceil_step(from); + if first_ms > to { + continue; + } + let mut values = Vec::new(); + let mut at = first_ms; + while at <= to { + let mut point = [f64::NAN; DeltaField::COUNT]; + let mut coverage = [0.0; DeltaField::COUNT]; + let coin = coin_eval.at(at); + let btc = btc_eval.at(at); + let fields = coin::FIELDS + .iter() + .zip(coin) + .chain(btc::FIELDS.iter().zip(btc)); + for (field, (value, covered)) in fields { + point[field.index()] = value.unwrap_or(f64::NAN); + coverage[field.index()] = covered; + } + if stamp_at == Some(at) { + at_stamp = Some((point, coverage)); + } + values.push(point); + at += STEP_MS; + } + segments.push(Segment { first_ms, values }); + } + let mut track = Self { + segments, + live: [false; DeltaField::COUNT], + stamp: StampCheck::default(), + }; + match inputs.anchor { + None => { + for field in DeltaField::ALL { + track.live[field.index()] = track + .segments + .iter() + .any(|s| s.values.iter().any(|v| v[field.index()].is_finite())); + } + } + Some((_, snapshot)) => { + let (estimate, coverage) = at_stamp?; + track.anchor(snapshot, &estimate, &coverage); + } + } + track.live.iter().any(|&l| l).then_some(track) + } + + /// Put every field the evaluation has at the stamp onto the report: shift it by what separates + /// the two there. A field the report holds at exactly zero where zero means "never filled" + /// ([`DeltaField::zero_is_unfilled`]) is not made live — the model keeps the report's zero, as + /// it did before the track. + fn anchor( + &mut self, + snapshot: &Deltas, + estimate: &Values, + coverage: &[f64; DeltaField::COUNT], + ) { + let mut offset = [0.0; DeltaField::COUNT]; + for field in DeltaField::ALL { + let i = field.index(); + let report = field.of(snapshot); + let unfilled = report == 0.0 && field.zero_is_unfilled(); + self.stamp.coverage[i] = coverage[i]; + if !estimate[i].is_finite() || unfilled { + continue; + } + self.stamp.error[i] = Some((estimate[i] - report).abs()); + self.live[i] = true; + offset[i] = report - estimate[i]; + } + for segment in &mut self.segments { + for point in &mut segment.values { + for field in DeltaField::ALL { + let i = field.index(); + let shifted = point[i] + offset[i]; + // BTC's hour deviation carries a sign; every other field is a size. + point[i] = if field == DeltaField::Btc1h { + shifted + } else { + shifted.max(0.0) + }; + } + } + } + } + + /// The deltas at a moment: the snapshot with every field this track answers for there + /// replaced. Outside the covered stretches, the snapshot as it is. + pub fn apply(&self, t_ms: i64, snapshot: &Deltas) -> Deltas { + let mut out = *snapshot; + if let Some(point) = self.segments.iter().find_map(|s| s.point(t_ms)) { + for field in DeltaField::ALL { + let value = point[field.index()]; + if self.live[field.index()] && value.is_finite() { + field.set(&mut out, value); + } + } + } + out + } + + /// One field's value at a moment, or `None` where the track does not answer for it. + pub fn value(&self, t_ms: i64, field: DeltaField) -> Option { + let point = self.segments.iter().find_map(|s| s.point(t_ms))?; + let value = point[field.index()]; + (self.live[field.index()] && value.is_finite()).then_some(value) + } + + /// Whether the track answers for a field at all. + pub fn is_live(&self, field: DeltaField) -> bool { + self.live[field.index()] + } + + /// What the evaluation found at the report's stamp, before the anchor. + pub fn stamp(&self) -> &StampCheck { + &self.stamp + } +} + +/// The first multiple of [`STEP_MS`] at or after a moment. +fn ceil_step(t_ms: i64) -> i64 { + t_ms.div_euclid(STEP_MS) * STEP_MS + + if t_ms.rem_euclid(STEP_MS) == 0 { + 0 + } else { + STEP_MS + } +} + +/// Where the models read a deal's deltas: from the run-up before its entry order's life began — +/// the creation, or the buy where the report stamps no creation the tape reaches — through the +/// close. An entry model placing its order off an earlier print, or a variant filling after the +/// close, reads the snapshot there. +pub fn eval_span(deal: &Deal) -> (i64, i64) { + let open = deal.order_open_ms().unwrap_or(deal.buy_ms); + (open - super::RUN_UP_MS, deal.close_ms) +} + +/// When the report stamped a trade's deltas (FAQ :423): at the buy for MoonShot, at the detect +/// and the placement of the buy order for every other kind — its creation stamp, where the +/// report has one. `None` for a creation the report does not stamp. +pub fn snapshot_ms(deal: &Deal) -> Option { + if deal.kind == KIND_MOONSHOT { + Some(deal.buy_ms) + } else { + deal.buy_set_ms + } +} + +/// A market's history bars over `[from_ms, to_ms]` off the kline cache: the one-minute bars, and +/// the five-minute ones wherever no minute bar lies. A read the cache did not answer in time is +/// an empty history, and the fields it would have fed stay on the snapshot. +/// +/// Args: +/// cache: The terminal's kline cache. +/// exchange_key: The cache's exchange key of the deal's core (`"{code}:{dex:08x}"`). +/// market: The market as the core spells it. +/// from_ms: The earliest moment wanted. +/// to_ms: The latest. +pub fn read_bars( + cache: &KlineCache, + exchange_key: &str, + market: &str, + from_ms: i64, + to_ms: i64, +) -> Vec { + let read = |kind_min: u32| { + let span_ms = i64::from(kind_min) * MINUTE_MS; + cache + .read_range(exchange_key, market, kind_min, from_ms, to_ms) + .unwrap_or_default() + .into_iter() + .filter(|c| c.t_open_ms.is_finite()) + .map(|c| { + let from_ms = c.t_open_ms as i64; + Bar { + from_ms, + to_ms: from_ms + span_ms, + open: f64::from(c.open), + high: f64::from(c.high), + low: f64::from(c.low), + close: f64::from(c.close), + } + }) + .collect::>() + }; + let minutes = read(1); + let mut bars = minutes.clone(); + bars.extend(read(5).into_iter().filter(|five| { + !minutes + .iter() + .any(|m| m.from_ms >= five.from_ms && m.from_ms < five.to_ms) + })); + bars.sort_by_key(|b| b.from_ms); + bars +} + +/// The track of one deal, from its tape and the kline cache — the one call the tuner's table and +/// the `real_data` bench both make, so what the bench measures is what the table replays. +/// +/// Args: +/// cache: The terminal's kline cache. +/// exchange_key: The cache's exchange key of the deal's core. +/// market: The deal's market as the core spells it. +/// btc_market: BTC's market on the same exchange, when the catalog names one; `None` leaves +/// the BTC fields on the snapshot. +/// deal: The trade, for its snapshot and the moment it was stamped. +/// ticks: The tape, ascending. +/// covered: The tape's coverage. +pub fn track_for( + cache: &KlineCache, + exchange_key: &str, + market: &str, + btc_market: Option<&str>, + deal: &Deal, + ticks: &[Tick], + covered: &Coverage, +) -> Option> { + // Only a track anchored on the report is the core's number (see the module doc): a trade + // without a stamp the tape reaches keeps the snapshot. + let at = snapshot_ms(deal)?; + let (from, to) = covered.hull()?; + let eval = eval_span(deal); + let coin_bars = read_bars( + cache, + exchange_key, + market, + from - LOOKBACK_MS - CANDLE_MS, + to, + ); + // A deal on BTC's own market reads BTC off its own bars. + let btc_bars = match btc_market { + Some(btc) if btc == market => coin_bars + .iter() + .filter(|b| b.to_ms > eval.0 - BTC_LOOKBACK_MS) + .copied() + .collect(), + Some(btc) => read_bars(cache, exchange_key, btc, eval.0 - BTC_LOOKBACK_MS, eval.1), + None => Vec::new(), + }; + DeltaTrack::build(TrackInputs { + coin_bars: &coin_bars, + ticks, + covered: covered.spans(), + btc_bars: &btc_bars, + eval, + anchor: Some((at, &deal.deltas)), + }) + .map(Arc::new) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/quality.rs b/crates/moon-core/src/db/tuner/ticks/deltas/quality.rs new file mode 100644 index 00000000..5d0cda23 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/quality.rs @@ -0,0 +1,88 @@ +//! How well the live deltas reproduce the core, over a sample of trades — what the tuner's +//! summary shows after a load, and what the `real_data` bench prints. +//! +//! The measure is the one thing the record can check: at the moment the report stamped its +//! deltas, the evaluation before the anchor against the report ([`super::StampCheck`]). The +//! anchor then puts every live field exactly on the report there, so this is not the error of the +//! values the model reads — it is how far the history the track stands on is from the core's own, +//! which is what the track's MOVES along the window inherit. + +use super::DeltaTrack; +use super::field::DeltaField; + +/// A stamp error at or under this counts as reproduced, per cent points. +pub const REPRODUCED_PP: f64 = 0.1; + +/// One field over the sample. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FieldQuality { + pub field: DeltaField, + /// Trades whose track answers for the field. + pub live: usize, + /// The median share of the field's window the history covered at the stamp, 0 … 1. + pub coverage_median: Option, + /// Trades with an error at the stamp — the field evaluated, and filled in the report. + pub checked: usize, + /// Of those, the ones within [`REPRODUCED_PP`]. + pub reproduced: usize, + /// The median error at the stamp, per cent points. + pub error_median: Option, +} + +/// Every field over the sample. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DeltaQuality { + /// Trades with a track at all. + pub tracks: usize, + /// One entry per field of [`DeltaField::ALL`], in its order. + pub fields: Vec, +} + +/// Sum the sample up. +/// +/// Args: +/// tracks: The tracks of the sample's trades. +pub fn summarize<'a>(tracks: impl IntoIterator) -> DeltaQuality { + let mut count = 0usize; + let mut live = [0usize; DeltaField::COUNT]; + let mut coverage: [Vec; DeltaField::COUNT] = std::array::from_fn(|_| Vec::new()); + let mut errors: [Vec; DeltaField::COUNT] = std::array::from_fn(|_| Vec::new()); + for track in tracks { + count += 1; + let stamp = track.stamp(); + for field in DeltaField::ALL { + let i = field.index(); + if !track.is_live(field) { + continue; + } + live[i] += 1; + coverage[i].push(stamp.coverage[i]); + if let Some(error) = stamp.error[i] { + errors[i].push(error); + } + } + } + let median = |values: &mut Vec| { + values.sort_by(f64::total_cmp); + values.get(values.len() / 2).copied() + }; + let fields = DeltaField::ALL + .iter() + .map(|&field| { + let i = field.index(); + let reproduced = errors[i].iter().filter(|&&e| e <= REPRODUCED_PP).count(); + FieldQuality { + field, + live: live[i], + coverage_median: median(&mut coverage[i]), + checked: errors[i].len(), + reproduced, + error_median: median(&mut errors[i]), + } + }) + .collect(); + DeltaQuality { + tracks: count, + fields, + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/series.rs b/crates/moon-core/src/db/tuner/ticks/deltas/series.rs new file mode 100644 index 00000000..fa8d4f7a --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/series.rs @@ -0,0 +1,169 @@ +//! What the deltas are read off: a market's history as bars and prints, the sliding extremes +//! of a window over it, and how much of a window the history covers. + +use std::collections::VecDeque; + +use super::Bar; +use crate::feed::types::Tick; + +/// What printed over a stretch — a bar, or one print — as the windows take it. +#[derive(Clone, Copy, Debug)] +pub(super) struct Item { + /// Where the stretch began — a print's own moment. + pub(super) from_ms: i64, + /// Exclusive end: the item is complete, and joins a window, at a boundary not before it. + pub(super) end_ms: i64, + pub(super) open: f64, + pub(super) high: f64, + pub(super) low: f64, + pub(super) close: f64, +} + +/// A market's history, ascending by end: the bars that do not overlap the tape, then the prints. +pub(super) struct Series { + pub(super) items: Vec, + /// The stretches the history covers, merged, for [`Series::covered_fraction`]. + spans: Vec<(i64, i64)>, +} + +impl Series { + /// Build from bars and prints. A bar overlapping the tape's coverage is left out — the prints + /// there are the truth, and a bar would carry prints from after the moment it is read at. + pub(super) fn new(bars: &[Bar], ticks: &[Tick], covered: &[(i64, i64)]) -> Self { + let overlaps_tape = |bar: &Bar| { + covered + .iter() + .any(|&(from, to)| bar.from_ms < to && bar.to_ms > from) + }; + let history: Vec<&Bar> = bars + .iter() + .filter(|b| { + b.low > 0.0 + && b.high >= b.low + && b.open > 0.0 + && b.close > 0.0 + && b.to_ms > b.from_ms + && !overlaps_tape(b) + }) + .collect(); + let mut items: Vec = history + .iter() + .map(|b| Item { + from_ms: b.from_ms, + end_ms: b.to_ms, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + }) + .collect(); + items.extend(ticks.iter().filter_map(|t| { + let price = f64::from(t.price); + (price.is_finite() && price > 0.0).then_some(Item { + from_ms: t.time_ms as i64, + end_ms: t.time_ms as i64 + 1, + open: price, + high: price, + low: price, + close: price, + }) + })); + items.sort_by_key(|i| i.end_ms); + let mut spans: Vec<(i64, i64)> = history + .iter() + .map(|b| (b.from_ms, b.to_ms)) + .chain(covered.iter().copied()) + .collect(); + spans.sort_unstable(); + let mut merged: Vec<(i64, i64)> = Vec::new(); + for (from, to) in spans { + match merged.last_mut() { + Some(last) if from <= last.1 => last.1 = last.1.max(to), + _ => merged.push((from, to)), + } + } + Self { + items, + spans: merged, + } + } + + /// The share of `[from, to)` the history covers, 0 … 1. + pub(super) fn covered_fraction(&self, from: i64, to: i64) -> f64 { + if to <= from { + return 0.0; + } + let covered: i64 = self + .spans + .iter() + .map(|&(a, b)| (b.min(to) - a.max(from)).max(0)) + .sum(); + covered as f64 / (to - from) as f64 + } +} + +/// The sliding extremes of one window: two monotone queues of item indices, the highs +/// decreasing and the lows increasing from the front, so the front of each is the extreme of +/// what the window holds. +pub(super) struct Extremes { + highs: VecDeque, + lows: VecDeque, +} + +impl Extremes { + pub(super) fn new() -> Self { + Self { + highs: VecDeque::new(), + lows: VecDeque::new(), + } + } + + pub(super) fn push(&mut self, index: usize, items: &[Item]) { + let item = items[index]; + while self + .highs + .back() + .is_some_and(|&i| items[i].high <= item.high) + { + self.highs.pop_back(); + } + self.highs.push_back(index); + while self.lows.back().is_some_and(|&i| items[i].low >= item.low) { + self.lows.pop_back(); + } + self.lows.push_back(index); + } + + /// The window's `(high, low)` at a boundary: what ended inside `(at − reach, at]` — a bar + /// that began before the window's start but ended inside it counts whole, as the core's + /// candle does. `None` when the window holds nothing. + pub(super) fn extremes_at( + &mut self, + at: i64, + reach: i64, + items: &[Item], + ) -> Option<(f64, f64)> { + let start = at - reach; + while self + .highs + .front() + .is_some_and(|&i| items[i].end_ms <= start) + { + self.highs.pop_front(); + } + while self.lows.front().is_some_and(|&i| items[i].end_ms <= start) { + self.lows.pop_front(); + } + match (self.highs.front(), self.lows.front()) { + (Some(&h), Some(&l)) => Some((items[h].high, items[l].low)), + _ => None, + } + } + + /// The window's range at a boundary, per cent — `(max / min − 1) · 100` — or `None` when + /// it holds nothing. + pub(super) fn range_at(&mut self, at: i64, reach: i64, items: &[Item]) -> Option { + let (high, low) = self.extremes_at(at, reach, items)?; + (low > 0.0).then(|| (high / low - 1.0) * 100.0) + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs b/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs index 9e21d7e0..5ff7307b 100644 --- a/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::feed::types::Side; -const T0: i64 = 1_790_000_000_000; // a multiple of STEP_MS +const T0: i64 = 1_790_000_000_000; // a multiple of STEP_MS and of a minute fn tick(t_ms: i64, price: f32) -> Tick { Tick { @@ -12,107 +12,152 @@ fn tick(t_ms: i64, price: f32) -> Tick { } } -/// Minute bars at a flat price from `from` to `to`, with one bar's extremes overridden. +/// Minute bars at a flat price from `from` to `to`. fn flat_bars(from: i64, to: i64, price: f64) -> Vec { (from..to) .step_by(MINUTE_MS as usize) .map(|t| Bar { from_ms: t, to_ms: t + MINUTE_MS, + open: price, high: price, low: price, + close: price, }) .collect() } +fn day_of_bars() -> Vec { + flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0) +} + fn close(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 } +/// A track without an anchor, over one covered stretch. +fn raw(bars: &[Bar], ticks: &[Tick], covered: (i64, i64), eval: (i64, i64)) -> DeltaTrack { + DeltaTrack::build(TrackInputs { + coin_bars: bars, + ticks, + covered: &[covered], + btc_bars: &[], + eval, + anchor: None, + }) + .unwrap() +} + #[test] fn a_delta_is_the_range_of_what_printed_before_the_boundary() { - // History a whole day back, flat at 100; the tape from T0 on. - let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); let ticks = [tick(T0 + 1_000, 100.0), tick(T0 + 7_000, 110.0)]; - let covered = [(T0, T0 + 60_000)]; - let track = DeltaTrack::build(&bars, &ticks, &covered, (T0, T0 + 60_000), None).unwrap(); + let track = raw(&day_of_bars(), &ticks, (T0, T0 + 60_000), (T0, T0 + 60_000)); // The print at +7 s joins at the next boundary, +10 s — not in the bucket it printed in. - let (at_5s, _) = track.at(T0 + 9_999).unwrap(); - assert!(close(at_5s.d1m, 0.0), "{at_5s:?}"); - let (at_10s, live) = track.at(T0 + 10_000).unwrap(); - assert!(close(at_10s.d1m, 10.0), "{at_10s:?}"); - assert!( - close(at_10s.d24h, 10.0) && close(at_10s.d3h, 10.0), - "{at_10s:?}" - ); - assert_eq!(live, [true; 6]); + assert_eq!(track.value(T0 + 9_999, DeltaField::D1m), Some(0.0)); + let d1m = track.value(T0 + 10_000, DeltaField::D1m).unwrap(); + assert!(close(d1m, 10.0), "{d1m}"); + for field in [DeltaField::D3h, DeltaField::D24h] { + assert!( + close(track.value(T0 + 10_000, field).unwrap(), 10.0), + "{field:?}" + ); + } } #[test] fn a_candle_window_reaches_one_candle_past_its_name() { // A spike to 120 in a bar that ended 19 minutes before T0: inside d15m's reach (20 min), and // out of it once the reach has passed it. - let mut bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); - let spike_end = T0 - 19 * MINUTE_MS; + let mut bars = day_of_bars(); for b in &mut bars { - if b.to_ms == spike_end { + if b.to_ms == T0 - 19 * MINUTE_MS { b.high = 120.0; } } let ticks = [tick(T0 + 1_000, 100.0)]; - let covered = [(T0, T0 + 5 * MINUTE_MS)]; - let track = DeltaTrack::build(&bars, &ticks, &covered, (T0, T0 + 5 * MINUTE_MS), None).unwrap(); - let (now, _) = track.at(T0).unwrap(); - assert!(close(now.d15m, 20.0), "{now:?}"); - assert!(close(now.d5m, 0.0), "{now:?}"); - // One minute later the bar ended 20 minutes ago: out of the window. - let (later, _) = track.at(T0 + MINUTE_MS).unwrap(); - assert!(close(later.d15m, 0.0), "{later:?}"); + let track = raw( + &bars, + &ticks, + (T0, T0 + 5 * MINUTE_MS), + (T0, T0 + 5 * MINUTE_MS), + ); + assert!(close(track.value(T0, DeltaField::D15m).unwrap(), 20.0)); + assert!(close(track.value(T0, DeltaField::D5m).unwrap(), 0.0)); + assert!(close( + track.value(T0 + MINUTE_MS, DeltaField::D15m).unwrap(), + 0.0 + )); // Still inside d1h, and the long ones never read below it. - assert!(close(later.d1h, 20.0) && close(later.d3h, 20.0) && close(later.d24h, 20.0)); + for field in [DeltaField::D1h, DeltaField::D3h, DeltaField::D24h] { + assert!( + close(track.value(T0 + MINUTE_MS, field).unwrap(), 20.0), + "{field:?}" + ); + } } #[test] -fn a_window_the_history_does_not_reach_back_for_keeps_the_snapshot() { - // Six hours of bars: d1m … d3h live, d24h not. - let bars = flat_bars(T0 - 6 * 60 * MINUTE_MS, T0, 100.0); +fn a_window_is_read_over_whatever_history_it_has() { + // Six hours of bars, with a hole in them: every window answers off what is there, and the + // stamp check says how much of each it covered. + let mut bars = flat_bars(T0 - 6 * 60 * MINUTE_MS, T0, 100.0); + bars.retain(|b| !(T0 - 30 * MINUTE_MS..T0 - 20 * MINUTE_MS).contains(&b.from_ms)); + bars[0].low = 95.0; let ticks = [tick(T0 + 1_000, 105.0)]; - let covered = [(T0, T0 + 60_000)]; - let track = DeltaTrack::build(&bars, &ticks, &covered, (T0, T0 + 60_000), None).unwrap(); - let (_, live) = track.at(T0 + 5_000).unwrap(); - assert_eq!(live, [true, true, true, true, true, false]); let snapshot = Deltas { - d24h: 42.0, - btc1h: 0.3, + d1m: 1.0, + d5m: 1.0, + d15m: 1.0, + d1h: 1.0, + d3h: 1.0, + d24h: 12.0, ..Deltas::default() }; - let applied = track.apply(T0 + 5_000, &snapshot); - assert!(close(applied.d1h, 5.0), "{applied:?}"); - assert!(close(applied.d24h, 42.0), "the snapshot: {applied:?}"); - assert!(close(applied.btc1h, 0.3), "never live: {applied:?}"); - // Outside the covered stretch, the snapshot whole. - assert_eq!(track.apply(T0 + 10 * MINUTE_MS, &snapshot), snapshot); - // A hole of more than a candle in the history is where it stops. - let mut holed = flat_bars(T0 - 6 * 60 * MINUTE_MS, T0, 100.0); - holed.retain(|b| !(T0 - 30 * MINUTE_MS..T0 - 20 * MINUTE_MS).contains(&b.from_ms)); - let track = DeltaTrack::build(&holed, &ticks, &covered, (T0, T0 + 60_000), None).unwrap(); - let (_, live) = track.at(T0 + 5_000).unwrap(); - assert_eq!(live, [true, true, true, false, false, false]); + let track = DeltaTrack::build(TrackInputs { + coin_bars: &bars, + ticks: &ticks, + covered: &[(T0, T0 + 60_000)], + btc_bars: &[], + eval: (T0, T0 + 60_000), + anchor: Some((T0 + 5_000, &snapshot)), + }) + .unwrap(); + for field in [DeltaField::D1h, DeltaField::D24h] { + assert!(track.is_live(field), "{field:?}"); + } + let stamp = track.stamp(); + let d24h = stamp.coverage[DeltaField::D24h.index()]; + assert!( + d24h > 0.2 && d24h < 0.25, + "six hours of twenty-five: {d24h}" + ); + let d1h = stamp.coverage[DeltaField::D1h.index()]; + assert!(d1h > 0.8 && d1h < 0.9, "a ten-minute hole in 65: {d1h}"); + // The six hours' range against the report's twelve: the error before the anchor. + let range = (105.0 / 95.0 - 1.0) * 100.0; + let error = stamp.error[DeltaField::D24h.index()].unwrap(); + assert!(close(error, 12.0 - range), "{error}"); + assert!(close(track.apply(T0 + 5_000, &snapshot).d24h, 12.0)); } #[test] -fn nothing_live_is_no_track() { - let ticks = [tick(T0 + 1_000, 100.0)]; +fn nothing_evaluated_is_no_track() { assert!( - DeltaTrack::build(&[], &ticks, &[(T0, T0 + 30_000)], (T0, T0 + 30_000), None).is_none() + DeltaTrack::build(TrackInputs { + coin_bars: &[], + ticks: &[], + covered: &[(T0, T0 + 30_000)], + btc_bars: &[], + eval: (T0, T0 + 30_000), + anchor: None, + }) + .is_none() ); } #[test] fn the_anchor_puts_the_track_on_the_report_at_its_stamp() { - let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); let ticks = [tick(T0 + 1_000, 100.0), tick(T0 + 12_000, 104.0)]; - let covered = [(T0, T0 + 60_000)]; // The report says 1 % on d1m at +5 s (the evaluation says 0) and never filled d15m. let snapshot = Deltas { d1m: 1.0, @@ -123,16 +168,19 @@ fn the_anchor_puts_the_track_on_the_report_at_its_stamp() { d24h: 9.0, ..Deltas::default() }; - let track = DeltaTrack::build( - &bars, - &ticks, - &covered, - (T0, T0 + 60_000), - Some((T0 + 5_000, &snapshot)), - ) + let track = DeltaTrack::build(TrackInputs { + coin_bars: &day_of_bars(), + ticks: &ticks, + covered: &[(T0, T0 + 60_000)], + btc_bars: &[], + eval: (T0, T0 + 60_000), + anchor: Some((T0 + 5_000, &snapshot)), + }) .unwrap(); let at_stamp = track.apply(T0 + 5_000, &snapshot); - assert_eq!(at_stamp, snapshot); + for field in [DeltaField::D1m, DeltaField::D1h, DeltaField::D24h] { + assert!(close(field.of(&at_stamp), field.of(&snapshot)), "{field:?}"); + } // After the 4 % print the track moved by 4 from where the report stood. let later = track.apply(T0 + 15_000, &snapshot); assert!( @@ -143,26 +191,27 @@ fn the_anchor_puts_the_track_on_the_report_at_its_stamp() { close(later.d15m, 0.0), "a field the report never filled: {later:?}" ); + assert!(!track.is_live(DeltaField::D15m)); + assert_eq!(track.stamp().error[DeltaField::D1m.index()], Some(1.0)); } #[test] fn an_anchor_the_track_does_not_reach_is_no_track() { - let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); let ticks = [tick(T0 + 1_000, 100.0)]; let snapshot = Deltas { d1m: 1.0, ..Deltas::default() }; // Stamped before the tape begins: nothing to put the track on the report with. - let stamp = Some((T0 - 60_000, &snapshot)); assert!( - DeltaTrack::build( - &bars, - &ticks, - &[(T0, T0 + 60_000)], - (T0, T0 + 60_000), - stamp - ) + DeltaTrack::build(TrackInputs { + coin_bars: &day_of_bars(), + ticks: &ticks, + covered: &[(T0, T0 + 60_000)], + btc_bars: &[], + eval: (T0, T0 + 60_000), + anchor: Some((T0 - 60_000, &snapshot)), + }) .is_none() ); // And a deal the report never stamped gets none either. @@ -172,67 +221,139 @@ fn an_anchor_the_track_does_not_reach_is_no_track() { assert_eq!(snapshot_ms(&deal), None); } -#[test] -fn a_field_not_live_at_the_stamp_is_not_live_anywhere() { - // Two stretches: the first reached back six hours, the second a whole day — d24h is live - // only in the second, and the stamp in the first gives it no offset. - let bars = flat_bars(T0 - 6 * 60 * MINUTE_MS, T0, 100.0); - let ticks = [tick(T0 + 1_000, 100.0)]; - let snapshot = Deltas { - d1m: 1.0, - d5m: 1.0, - d15m: 1.0, - d1h: 1.0, - d3h: 1.0, - d24h: 7.0, - ..Deltas::default() - }; - let track = DeltaTrack::build( - &bars, - &ticks, - &[(T0, T0 + 60_000)], - (T0, T0 + 60_000), - Some((T0 + 5_000, &snapshot)), - ) - .unwrap(); - let (_, live) = track.at(T0 + 30_000).unwrap(); - assert!(!live[5], "{live:?}"); - assert!(close(track.apply(T0 + 30_000, &snapshot).d24h, 7.0)); -} - #[test] fn a_bar_under_the_tape_is_not_history() { // A bar overlapping the tape carries prints from after the moment it would be read at. - let mut bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); + let mut bars = day_of_bars(); bars.push(Bar { from_ms: T0, to_ms: T0 + MINUTE_MS, + open: 100.0, high: 150.0, low: 100.0, + close: 100.0, }); let ticks = [tick(T0 + 1_000, 100.0)]; - let covered = [(T0, T0 + MINUTE_MS)]; - let track = DeltaTrack::build(&bars, &ticks, &covered, (T0, T0 + MINUTE_MS), None).unwrap(); - let (late, _) = track.at(T0 + 55_000).unwrap(); - assert!(close(late.d1h, 0.0), "{late:?}"); + let track = raw(&bars, &ticks, (T0, T0 + MINUTE_MS), (T0, T0 + MINUTE_MS)); + assert!(close( + track.value(T0 + 55_000, DeltaField::D1h).unwrap(), + 0.0 + )); } #[test] fn the_track_is_evaluated_only_where_the_models_read_it() { - let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); let ticks = [tick(T0 + 1_000, 100.0)]; - let covered = [(T0, T0 + 60 * MINUTE_MS)]; - let track = DeltaTrack::build( - &bars, + let track = raw( + &day_of_bars(), &ticks, - &covered, + (T0, T0 + 60 * MINUTE_MS), (T0 + MINUTE_MS, T0 + 2 * MINUTE_MS), - None, - ) + ); + assert!(track.value(T0 + 30_000, DeltaField::D1m).is_none()); + assert!(track.value(T0 + MINUTE_MS, DeltaField::D1m).is_some()); + assert!(track.value(T0 + 3 * MINUTE_MS, DeltaField::D1m).is_none()); +} + +#[test] +fn d5s_is_the_move_over_the_last_bucket() { + let ticks = [ + tick(T0 + 1_000, 100.0), + tick(T0 + 6_000, 102.0), + tick(T0 + 8_000, 101.0), + ]; + let track = raw(&day_of_bars(), &ticks, (T0, T0 + 20_000), (T0, T0 + 20_000)); + // No previous boundary for the first point. + assert!(track.value(T0, DeltaField::D5s).is_none()); + // +10 s: the last price before it (101) against the last before +5 s (100). + assert!(close( + track.value(T0 + 10_000, DeltaField::D5s).unwrap(), + 1.0 + )); + // +15 s: nothing printed in the bucket. + assert!(close( + track.value(T0 + 15_000, DeltaField::D5s).unwrap(), + 0.0 + )); +} + +#[test] +fn pump_and_dump_run_off_the_price_an_hour_ago() { + // An hour ago the price was 100; within the hour it touched 110 and 95. + let mut bars = flat_bars(T0 - 2 * 60 * MINUTE_MS, T0, 100.0); + for b in &mut bars { + if b.from_ms == T0 - 30 * MINUTE_MS { + b.high = 110.0; + } + if b.from_ms == T0 - 20 * MINUTE_MS { + b.low = 95.0; + } + } + let ticks = [tick(T0 + 1_000, 100.0)]; + let track = raw(&bars, &ticks, (T0, T0 + 60_000), (T0, T0 + 60_000)); + assert!(close(track.value(T0, DeltaField::Pump1h).unwrap(), 10.0)); + assert!(close(track.value(T0, DeltaField::Dump1h).unwrap(), 5.0)); +} + +#[test] +fn btc_reads_its_own_market() { + // BTC flat at 50 000 for four hours, then its last five minutes range 50 000 … 50 500. + let mut btc = flat_bars(T0 - 4 * 60 * MINUTE_MS, T0, 50_000.0); + let last = btc.len() - 1; + btc[last].high = 50_500.0; + btc[last].close = 50_500.0; + let ticks = [tick(T0 + 1_000, 1.0)]; + let track = DeltaTrack::build(TrackInputs { + coin_bars: &flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 1.0), + ticks: &ticks, + covered: &[(T0, T0 + 60_000)], + btc_bars: &btc, + eval: (T0, T0 + 60_000), + anchor: None, + }) + .unwrap(); + assert!(close(track.value(T0, DeltaField::Btc1m).unwrap(), 1.0)); + assert!(close(track.value(T0, DeltaField::Btc5m).unwrap(), 1.0)); + // One minute bar is two of the average's steps: it moved 1 − 0.99² of the way up. + let average = 50_000.0 + 500.0 * (1.0 - 0.99f64.powi(2)); + let expected = (50_500.0 - average) / average * 100.0; + assert!(close(track.value(T0, DeltaField::Btc1h).unwrap(), expected)); + // Without BTC's bars the fields keep the snapshot. + let snapshot = Deltas { + btc5m: 0.3, + ..Deltas::default() + }; + let bare = raw(&day_of_bars(), &ticks, (T0, T0 + 60_000), (T0, T0 + 60_000)); + assert!(close(bare.apply(T0, &snapshot).btc5m, 0.3)); +} + +#[test] +fn a_btc_field_the_report_left_at_zero_keeps_the_snapshot() { + // A replica without the BTC columns, or a core without BTC's prices, files zeros: the track + // must not put a live BTC field onto them. BTC moves 2 % after the stamp, so a live btc5m + // would read it. + let mut btc = flat_bars(T0 - 4 * 60 * MINUTE_MS, T0 + MINUTE_MS, 50_000.0); + let last = btc.len() - 1; + btc[last].high = 51_000.0; + let snapshot = Deltas { + d1h: 1.0, + btc5m: 0.0, + btc1h: 0.2, + ..Deltas::default() + }; + let track = DeltaTrack::build(TrackInputs { + coin_bars: &day_of_bars(), + ticks: &[tick(T0 + 1_000, 100.0)], + covered: &[(T0, T0 + 60_000)], + btc_bars: &btc, + eval: (T0, T0 + 60_000), + anchor: Some((T0 + 5_000, &snapshot)), + }) .unwrap(); - assert!(track.at(T0 + 30_000).is_none()); - assert!(track.at(T0 + MINUTE_MS).is_some()); - assert!(track.at(T0 + 3 * MINUTE_MS).is_none()); + assert!(!track.is_live(DeltaField::Btc5m)); + assert!(track.is_live(DeltaField::Btc1h)); + // At +60 s the 2 % bar is in the window: the snapshot's 0 holds, not the move. + assert!(close(track.apply(T0 + 60_000, &snapshot).btc5m, 0.0)); } #[test] @@ -243,8 +364,6 @@ fn the_snapshot_is_stamped_at_the_buy_for_moonshot_and_at_the_creation_otherwise assert_eq!(snapshot_ms(&deal), Some(deal.buy_ms)); deal.kind = "MoonHook".into(); assert_eq!(snapshot_ms(&deal), Some(deal.buy_ms - 60_000)); - deal.buy_set_ms = None; - assert_eq!(snapshot_ms(&deal), None); } #[test] @@ -252,11 +371,40 @@ fn a_deal_reads_its_track_and_falls_back_to_the_snapshot() { let mut deal = crate::db::tuner::ticks::tests::deal(); deal.deltas.d1m = 3.0; assert!(close(deal.deltas_at(deal.buy_ms).d1m, 3.0)); - let bars = flat_bars(T0 - LOOKBACK_MS - CANDLE_MS, T0, 100.0); let ticks = [tick(T0 + 1_000, 100.0), tick(T0 + 2_000, 102.0)]; - let track = - DeltaTrack::build(&bars, &ticks, &[(T0, T0 + 60_000)], (T0, T0 + 60_000), None).unwrap(); + let track = raw(&day_of_bars(), &ticks, (T0, T0 + 60_000), (T0, T0 + 60_000)); deal.delta_track = Some(Arc::new(track)); assert!(close(deal.deltas_at(T0 + 5_000).d1m, 2.0)); assert!(close(deal.deltas_at(T0 - 60_000).d1m, 3.0)); } + +#[test] +fn the_summary_counts_live_fields_coverage_and_the_stamp_errors() { + let snapshot = Deltas { + d1m: 1.0, + d1h: 1.0, + ..Deltas::default() + }; + let build = |bars: &[Bar]| { + DeltaTrack::build(TrackInputs { + coin_bars: bars, + ticks: &[tick(T0 + 1_000, 100.0)], + covered: &[(T0, T0 + 60_000)], + btc_bars: &[], + eval: (T0, T0 + 60_000), + anchor: Some((T0 + 5_000, &snapshot)), + }) + .unwrap() + }; + let full = build(&day_of_bars()); + let short = build(&flat_bars(T0 - 30 * MINUTE_MS, T0, 100.0)); + let quality = summarize([&full, &short]); + assert_eq!(quality.tracks, 2); + let d1h = quality.fields[DeltaField::D1h.index()]; + assert_eq!((d1h.live, d1h.checked), (2, 2)); + // Both evaluated 0 against the report's 1: an error of 1 pp, not within 0.1. + assert_eq!(d1h.reproduced, 0); + assert_eq!(d1h.error_median, Some(1.0)); + // A field the report left at zero is not live anywhere. + assert_eq!(quality.fields[DeltaField::D15m.index()].live, 0); +} diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index e8722624..f11ef4a3 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -120,13 +120,13 @@ pub fn round_to_step(level: f64, tick: f64) -> f64 { /// on the sell and the stop). /// /// The report stamps them once — at the buy for MoonShot, at the detect and the order's -/// placement for every other kind (FAQ :423) — while the core re-reads them live. The coin's -/// own ranges (`d1m … d24h`) are re-evaluated along the window where the caller could build a -/// [`deltas::DeltaTrack`] for the deal ([`Deal::deltas_at`]); the rest stay this snapshot. All -/// values are per cent, exactly as `orders_rep` stores them. +/// placement for every other kind (FAQ :423) — while the core re-reads them live. Every field of +/// [`deltas::DeltaField`] is re-evaluated along the window where the caller could build a +/// [`deltas::DeltaTrack`] for the deal ([`Deal::deltas_at`]); the rest ([`deltas::NotComputed`]) +/// stay this snapshot. All values are per cent, exactly as `orders_rep` stores them. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Deltas { - /// The last five seconds' move (`d5s`) — the spike itself; shown, not a modifier input. + /// The last five seconds' move (`d5s`) — read by `MShotAdd5sDelta`. pub d5s: f64, pub d1m: f64, pub d5m: f64, @@ -210,7 +210,8 @@ pub struct Deal { /// The report's snapshot of the deltas; read through [`Deal::deltas_at`], never directly by a /// model, so the live track applies wherever the deal has one. pub deltas: Deltas, - /// The coin's own deltas along the window, as the core re-evaluated them + /// The deltas along the window, as the core re-evaluated them — every field of + /// [`deltas::DeltaField`] the history could give and the report filled /// ([`deltas::track_for`]); filled by the caller that holds the tape, before the other model /// inputs (the stop anchor reads the stop through it). `None` keeps the snapshot everywhere. pub delta_track: Option>, @@ -271,8 +272,8 @@ impl Deal { order_open_at(self.buy_ms, self.buy_set_ms) } - /// The deltas as the core held them at a moment: the live track's coin ranges where the - /// deal has one, the report's snapshot for everything else. + /// The deltas as the core held them at a moment: every field the live track answers for + /// there, where the deal has one, the report's snapshot for everything else. pub fn deltas_at(&self, t_ms: i64) -> Deltas { match &self.delta_track { Some(track) => track.apply(t_ms, &self.deltas), diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index 3a6dca0c..3395889c 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -102,6 +102,9 @@ pub enum MarketSign { /// `Add*` on the sell and the stop: per cent added per one per cent of the matching delta. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Modifiers { + /// `MShotAdd5sDelta` (the exe's `MShotAdd*` list; no FAQ entry, no live strategy sets it on + /// 2026-09-23); the Delta Modifiers tab has no such field and leaves it at zero. + pub add_5s: f64, pub add_1m: f64, pub add_5m: f64, pub add_15m: f64, @@ -139,7 +142,8 @@ impl Modifiers { MarketSign::Signed => delta, MarketSign::Magnitude => delta.abs(), }; - self.add_1m * d.d1m + self.add_5s * d.d5s + + self.add_1m * d.d1m + self.add_5m * d.d5m + self.add_15m * d.d15m + self.add_1h * d.d1h diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 1d8a12a1..34ecf2b6 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -408,6 +408,8 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "AddMarket24Delta", "AddPump1h", "AddDump1h", + // The corridor family's one modifier the grid does not offer (no live strategy sets it). + "MShotAdd5sDelta", ]; /// Every field name the models read — [`TICK_PARAMS`] plus [`MODEL_ONLY_KEYS`] — for a @@ -480,6 +482,7 @@ pub fn mshot_params(v: &StrategyValues<'_>, latency_ms: f64) -> MshotParams { minus_satoshi: v.bool("MShotMinusSatoshi", base.minus_satoshi), fast_algo: v.bool("FastShotAlgo", base.fast_algo), modifiers: Modifiers { + add_5s: v.num("MShotAdd5sDelta", 0.0), add_1m: v.num("MShotAdd1minDelta", 0.0), add_5m: v.num("MShotAdd5minDelta", 0.0), add_15m: v.num("MShotAdd15minDelta", 0.0), @@ -521,6 +524,7 @@ pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { // corridor; a strategy can carry both, and reading one for the other would move the // sell by the buy's coefficients. sell_mods: Modifiers { + add_5s: 0.0, add_1m: v.num("Add1minDelta", 0.0), add_5m: v.num("Add5minDelta", 0.0), add_15m: v.num("Add15minDelta", 0.0), diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 450e8007..b7e780b1 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -232,75 +232,60 @@ fn round3(v: Option) -> Option { v.map(|d| (d * 1000.0).round() / 1000.0) } -/// How closely the live deltas, evaluated WITHOUT the anchor, reproduce the report's own snapshot -/// at the moment it was stamped — the check that the windows are the core's. Per field of -/// `deltas::CoinDeltas`: live answers, exact ones (1e-6 pp), ones within 0.1 pp, the errors. -#[derive(Default)] -struct DeltaFidelity { - tracks: usize, - no_stamp: usize, - n: [usize; 6], - exact: [usize; 6], - close: [usize; 6], - errors: [Vec; 6], -} - -impl DeltaFidelity { - fn observe( - &mut self, - deal: &Deal, - cache: &KlineCache, - exchange: &str, - market: &str, - ticks: &[Tick], - covered: &Coverage, - ) { - let Some(at) = deltas::snapshot_ms(deal) else { - self.no_stamp += 1; - return; - }; - let bars = deltas::read_bars(cache, exchange, market, covered); - let Some(track) = - deltas::DeltaTrack::build(&bars, ticks, covered.spans(), deltas::eval_span(deal), None) - else { - return; - }; - self.tracks += 1; - let Some((est, live)) = track.at(at) else { - return; - }; - let d = &deal.deltas; - let report = [d.d1m, d.d5m, d.d15m, d.d1h, d.d3h, d.d24h]; - let model = [est.d1m, est.d5m, est.d15m, est.d1h, est.d3h, est.d24h]; - for field in 0..6 { - if !live[field] || report[field] == 0.0 { - continue; +/// BTC's market on every exchange the kline cache holds bars for — the BTC deltas are read off +/// it, as the table reads them off the market the catalog names (`FetchResolver`). The cache's +/// own spelling, since no core is connected here: a market whose coin is BTC, a USDT one first. +fn btc_markets() -> HashMap { + let Ok(db) = + Connection::open_with_flags(paths::klines_db_path(), OpenFlags::SQLITE_OPEN_READ_ONLY) + else { + return HashMap::new(); + }; + let Ok(mut stmt) = db.prepare("SELECT DISTINCT exchange, market FROM chunks_v2") else { + return HashMap::new(); + }; + let pairs: Vec<(String, String)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default(); + let btc = coin_match_key("BTC"); + let mut out: HashMap = HashMap::new(); + for (exchange, market) in pairs { + if coin_match_key(coin_of_market(&market)) != btc { + continue; + } + let usdt = market.to_ascii_uppercase().contains("USDT"); + match out.get(&exchange) { + Some(held) if held.to_ascii_uppercase().contains("USDT") || !usdt => {} + _ => { + out.insert(exchange, market); } - let err = (model[field] - report[field]).abs(); - self.n[field] += 1; - self.exact[field] += usize::from(err <= 1e-6); - self.close[field] += usize::from(err <= 0.1); - self.errors[field].push(err); } } + out +} - fn report(&mut self) { +/// The delta summary the table shows (`deltas::summarize`), printed. +fn print_delta_quality(tracks: &[std::sync::Arc], no_track: usize) { + let quality = deltas::summarize(tracks.iter().map(|t| t.as_ref())); + eprintln!( + "live deltas: {} tracks, {} deals without one (no stamp the tape reaches); at the stamp, before the anchor:", + quality.tracks, no_track + ); + for field in &quality.fields { eprintln!( - "live deltas against the report's snapshot at its stamp (no anchor): {} tracks, {} deals with no stamp", - self.tracks, self.no_stamp + " {:10} live {:4} · window covered {:>5} · within 0.1 pp {:4} of {:4} · median |err| {} pp", + field.field.column(), + field.live, + field + .coverage_median + .map_or("—".to_string(), |c| format!("{:.0}%", c * 100.0)), + field.reproduced, + field.checked, + field + .error_median + .map_or("—".to_string(), |e| format!("{e:.4}")), ); - for (field, name) in ["d1m", "d5m", "d15m", "d1h", "d3h", "d24h"] - .iter() - .enumerate() - { - let errors = &mut self.errors[field]; - errors.sort_by(f64::total_cmp); - let median = errors.get(errors.len() / 2).copied().unwrap_or(f64::NAN); - eprintln!( - " {name:5} live {:4} · exact {:4} · within 0.1 pp {:4} · median |err| {median:.4} pp", - self.n[field], self.exact[field], self.close[field] - ); - } } } @@ -372,7 +357,10 @@ fn real_data_reproduction() { "no kline cache — snapshot" } ); - let mut fidelity = DeltaFidelity::default(); + let btc_of_exchange = btc_markets(); + eprintln!("BTC markets: {btc_of_exchange:?}"); + let mut tracks: Vec> = Vec::new(); + let mut no_track = 0usize; eprintln!("PriceDown step lag per core: {core_lags:?}"); let (mut entry_hits, mut entry_n, mut exit_hits, mut exit_n, mut with_tape) = (0, 0, 0, 0, 0); let mut kinds_seen: HashMap = HashMap::new(); @@ -472,10 +460,14 @@ fn real_data_reproduction() { let exit = exit_params(&sv); deal.step_lag_ms = core_lags.get(&deal.core_uid).copied().unwrap_or(0.0); if let (Some(cache), Some((exchange, market))) = (klines.as_ref(), address.as_ref()) { - fidelity.observe(&deal, cache, exchange, market, &ticks, &covered); + let btc = btc_of_exchange.get(exchange).map(String::as_str); + let track = deltas::track_for(cache, exchange, market, btc, &deal, &ticks, &covered); + match &track { + Some(track) => tracks.push(track.clone()), + None => no_track += 1, + } if !snapshot_only { - deal.delta_track = - deltas::track_for(cache, exchange, market, &deal, &ticks, &covered); + deal.delta_track = track; } } prepare_deal( @@ -684,7 +676,7 @@ fn real_data_reproduction() { "entry from the order's creation: ✓ {created_hits}/{created_n} · stamped entries {stamped}" ); eprintln!("saved corridors the model's band matches to 0.05 %: {corridor_hits}/{corridor_n}"); - fidelity.report(); + print_delta_quality(&tracks, no_track); let mut unfit: Vec<(String, usize)> = unfit.into_iter().collect(); unfit.sort_by_key(|u| std::cmp::Reverse(u.1)); eprintln!("fit for the search: {fit_n} of {with_tape} · left out: {unfit:?}"); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary.rs new file mode 100644 index 00000000..19ca4e17 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary.rs @@ -0,0 +1,74 @@ +//! The status line's delta part: how many rows the model reads live deltas for, and — in its +//! tooltip — how well each delta's history reproduces the core, field by field +//! (`deltas::summarize`), with the deltas the model does not re-evaluate and why. + +use rust_i18n::t; + +use super::state::{DealRow, TapeStatus}; +use moon_core::db::tuner::ticks::deltas::{self, NotComputed}; + +/// The caption and its tooltip, or `None` while no row has its tape. +pub(super) fn delta_summary(rows: &[DealRow]) -> Option<(String, String)> { + let covered = rows + .iter() + .filter(|r| r.tape == TapeStatus::Covered) + .count(); + if covered == 0 { + return None; + } + let tracks = rows + .iter() + .filter(|r| r.tape == TapeStatus::Covered) + .filter_map(|r| r.deal.delta_track.as_deref()); + let quality = deltas::summarize(tracks); + let caption = t!( + "analytics.ticks.deltas_caption", + tracked = quality.tracks, + covered = covered + ) + .to_string(); + let mut tip = vec![t!("analytics.ticks.deltas_tip_head").to_string()]; + for field in &quality.fields { + tip.push(field_line(field)); + } + for missing in NotComputed::ALL { + let reason = match missing { + NotComputed::MarkPrice => t!("analytics.ticks.deltas_tip_mark"), + NotComputed::PriceBug => t!("analytics.ticks.deltas_tip_pricebug"), + NotComputed::Market => t!("analytics.ticks.deltas_tip_market"), + }; + tip.push(format!("{}: {reason}", missing.columns())); + } + Some((caption, tip.join("\n"))) +} + +/// One field's line of the tooltip. +fn field_line(field: &deltas::FieldQuality) -> String { + let name = field.field.column(); + if field.live == 0 { + return t!("analytics.ticks.deltas_tip_none", name = name).to_string(); + } + let coverage = field + .coverage_median + .map_or_else(|| "—".to_string(), |c| format!("{:.0}", c * 100.0)); + let error = field + .error_median + .map_or_else(|| "—".to_string(), |e| format!("{e:.3}")); + let mut line = t!( + "analytics.ticks.deltas_tip_field", + name = name, + live = field.live, + coverage = coverage, + reproduced = field.reproduced, + checked = field.checked, + error = error + ) + .to_string(); + if field.field.is_btc() { + line.push_str(&t!("analytics.ticks.deltas_tip_btc_note")); + } + line +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs new file mode 100644 index 00000000..3b57ec38 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs @@ -0,0 +1,67 @@ +use super::super::state::{DealRow, TapeStatus}; +use super::delta_summary; +use moon_core::db::tuner::ticks::deltas::DeltaField; +use moon_core::db::tuner::ticks::{Deal, Deltas}; + +fn row(tape: TapeStatus) -> DealRow { + DealRow { + deal: Deal { + report_uid: 1, + core_uid: 1, + core_name: String::new(), + strategy_id: 1, + kind: "MoonShot".into(), + coin: "ACE".into(), + buy_ms: 1_000, + close_ms: 2_000, + buy_price: 1.0, + sell_price: 1.0, + spent: 1.0, + is_short: false, + sell_reason: String::new(), + fact_pnl: 0.0, + profit: None, + deltas: Deltas::default(), + delta_track: None, + tick: None, + pre_spike_ask: None, + archived_take: None, + hook_depth_pct: None, + hook_stated_take_pct: None, + step_lag_ms: 0.0, + stop_anchor: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + }, + tape, + verdict: None, + address: None, + ticks: None, + entry_line: None, + held: None, + } +} + +#[test] +fn no_row_with_its_tape_says_nothing() { + assert!(delta_summary(&[row(TapeStatus::Missing)]).is_none()); +} + +#[test] +fn the_tooltip_names_every_delta_computed_or_not() { + let _locale = crate::test_locale::force("en"); + let (caption, tip) = delta_summary(&[row(TapeStatus::Covered)]).unwrap(); + assert!(caption.contains("0 of 1"), "{caption}"); + for field in DeltaField::ALL { + assert!( + tip.contains(field.column()), + "{} missing from {tip}", + field.column() + ); + } + for column in ["dmark", "pricebug", "exchange1hdelta", "exchange24hdelta"] { + assert!(tip.contains(column), "{column} missing from {tip}"); + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index a8eb94c9..3619c2ad 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -39,6 +39,8 @@ pub(in crate::analytics::tuner) struct FetchResolver { /// Per distinct `(core, coin)`, what it resolved to — a table of one coin's trades asks /// the catalog once. addresses: HashMap<(u64, String), Option>>, + /// Per core, BTC's market on its exchange — the BTC deltas are read off it. + btc_markets: HashMap>, } impl FetchResolver { @@ -52,6 +54,7 @@ impl FetchResolver { .map(|s| (s.id, s.market.clone())) .collect(), addresses: HashMap::new(), + btc_markets: HashMap::new(), } } @@ -68,6 +71,12 @@ impl FetchResolver { .get(&deal.core_uid) .map(String::as_str) .unwrap_or_default(); + let source = &self.source; + let btc_market = self + .btc_markets + .entry(deal.core_uid) + .or_insert_with(|| source.resolve_market(deal.core_uid, quote, "BTC")) + .clone(); let address = self .source .replay_address(deal.core_uid) @@ -82,6 +91,7 @@ impl FetchResolver { venue: address.venue, exchange_key: address.exchange_key, market, + btc_market, tick, })) }); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 2cb846de..5f97dbef 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -627,13 +627,14 @@ pub(super) fn replay_row_with( EntryParams::Fact }; let exit = params::exit_params(&sv); - // The coin's deltas along the window, before the record's inputs: the stop anchor reads the - // stop through them. + // The deltas along the window, before the record's inputs: the stop anchor reads the stop + // through them. row.deal.delta_track = klines.and_then(|cache| { deltas::track_for( cache, &address.exchange_key, &address.market, + address.btc_market.as_deref(), &row.deal, &ticks, &covered, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index a13d42c7..ca2f64e0 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -36,6 +36,7 @@ use state::SuggState; use state::{DealRow, TapeStatus}; pub(in crate::analytics::tuner) mod columns; +mod delta_summary; pub(crate) mod fetch; mod grid; mod lags; @@ -197,6 +198,20 @@ impl AnalyticsView { // hides the rest. How well the MODEL does on that sample is the KPI caption's ✓ // shares (`ticks_kpi`), not a size. let status = coverage_caption(covered, fit, total, without_ms, left_out.1, left_out.2); + // How many rows read live deltas, and — in the tooltip — how well each one's history + // reproduces the core (`delta_summary`). + let deltas = self + .ticks + .data + .data() + .and_then(|d| delta_summary::delta_summary(&d.rows)) + .map(|(caption, tip)| { + div() + .id("an-ticks-deltas") + .flex_none() + .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(tip.clone())).into()) + .child(caption) + }); let only_tip = t!("analytics.ticks.only_fit_tip").to_string(); let only_switch = div() .id("an-ticks-only-tape-box") @@ -315,6 +330,7 @@ impl AnalyticsView { .text_size(design::t_caption(cx)) .text_color(moon(p.text_muted)) .child(div().flex_1().min_w_0().truncate().child(status)) + .children(deltas) .child(only_switch), ) .into_any_element() diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index a1c8d106..11147248 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -114,6 +114,9 @@ pub(in crate::analytics::tuner) struct RowAddress { pub(in crate::analytics::tuner) venue: moon_core::venue::Venue, pub(in crate::analytics::tuner) exchange_key: String, pub(in crate::analytics::tuner) market: String, + /// BTC's market on the same exchange, as the catalog spells it — the BTC deltas are read off + /// its bars; `None` when the catalog names none, and those deltas keep the snapshot. + pub(in crate::analytics::tuner) btc_market: Option, /// The market's price step from the live catalog, when the core reports it. pub(in crate::analytics::tuner) tick: Option, } diff --git a/locales/analytics.yml b/locales/analytics.yml index 199e0fb5..2b97aa2f 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1720,6 +1720,38 @@ analytics.ticks.coverage_untunable: ru: "вне тюнинга %{n}" en: "outside tuning %{n}" es: "fuera del ajuste %{n}" +analytics.ticks.deltas_caption: + ru: "дельты: живые у %{tracked} из %{covered}" + en: "deltas: live on %{tracked} of %{covered}" + es: "deltas: vivas en %{tracked} de %{covered}" +analytics.ticks.deltas_tip_head: + ru: "Дельты пересчитываются вдоль окна сделки так, как их считает ядро, — по ленте и свечам монеты и BTC той же биржи, по той истории, что есть, — и в момент снимка отчёта ставятся ровно на отчёт. У сделки без отметки момента снимка (до 21.09, кроме MoonShot) дельты остаются снимком. Ниже по каждой дельте: у скольких сделок она живая, какую долю окна покрыла история и насколько пересчёт совпал с отчётом в момент снимка ДО привязки — это мера того, насколько верны её движения в остальные моменты." + en: "Deltas are re-evaluated along the trade's window the way the core computes them — off the tape and the bars of the coin and of BTC on the same exchange, over whatever history there is — and put exactly on the report at the moment it was stamped. A trade without that stamp (before 21.09, MoonShot aside) keeps the snapshot. Per delta below: on how many trades it is live, how much of its window the history covered, and how close the evaluation came to the report at the stamp BEFORE the anchor — the measure of how right its moves are at every other moment." + es: "Los deltas se recalculan a lo largo de la ventana de la operación como los calcula el núcleo — con la cinta y las velas de la moneda y del BTC del mismo exchange, sobre el historial que haya — y en el momento de la instantánea del informe se ajustan exactamente al informe. Una operación sin esa marca (antes del 21.09, salvo MoonShot) conserva la instantánea. Por cada delta: en cuántas operaciones está viva, qué parte de su ventana cubrió el historial y cuánto coincidió el cálculo con el informe en la instantánea ANTES del ajuste — la medida de cuán correctos son sus movimientos en los demás momentos." +analytics.ticks.deltas_tip_field: + ru: "%{name}: живая у %{live} · окно покрыто на %{coverage}% · в снимке ≤0,1 п.п. у %{reproduced} из %{checked}, медиана %{error} п.п." + en: "%{name}: live on %{live} · window covered %{coverage}% · at the stamp ≤0.1 pp on %{reproduced} of %{checked}, median %{error} pp" + es: "%{name}: viva en %{live} · ventana cubierta %{coverage}% · en la instantánea ≤0,1 pp en %{reproduced} de %{checked}, mediana %{error} pp" +analytics.ticks.deltas_tip_none: + ru: "%{name}: не живая ни у одной сделки — снимок отчёта" + en: "%{name}: live on no trade — the report's snapshot" + es: "%{name}: no está viva en ninguna operación — la instantánea del informe" +analytics.ticks.deltas_tip_btc_note: + ru: " (по свечам BTC из кеша; на 5-минутных свечах 1m — размах свечи)" + en: " (off BTC's cached bars; on five-minute bars 1m is a bar's range)" + es: " (con las velas de BTC en caché; con velas de 5 minutos, 1m es el rango de una vela)" +analytics.ticks.deltas_tip_mark: + ru: "снимок отчёта — истории цены маркировки нет" + en: "the report's snapshot — there is no history of the mark price" + es: "la instantánea del informe — no hay historial del precio de marca" +analytics.ticks.deltas_tip_pricebug: + ru: "снимок отчёта — это собственная мера лага ядра" + en: "the report's snapshot — the core's own lag measure" + es: "la instantánea del informe — la medida propia de retraso del núcleo" +analytics.ticks.deltas_tip_market: + ru: "снимок отчёта — средняя по всем рынкам биржи" + en: "the report's snapshot — an average over every market of the exchange" + es: "la instantánea del informe — un promedio de todos los mercados del exchange" analytics.ticks.only_fit: ru: "только годные" en: "reproduced only" From 8cb426319c1c7e25968aa5d7f0d200821f3ba67d Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 16:37:21 +0200 Subject: [PATCH 24/51] feat(tuner): read the stop off the ticker, divide a short MoonShot take, 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. --- .../moon-core/src/db/tuner/ticks/calibrate.rs | 30 ++- .../src/db/tuner/ticks/calibrate/tests.rs | 21 ++ crates/moon-core/src/db/tuner/ticks/exit.rs | 71 +++-- crates/moon-core/src/db/tuner/ticks/line.rs | 255 ++++++++++++++---- .../src/db/tuner/ticks/line/tests.rs | 184 ++++++++++--- crates/moon-core/src/db/tuner/ticks/mshot.rs | 179 ++++++++---- .../src/db/tuner/ticks/record/tests.rs | 6 +- crates/moon-core/src/db/tuner/ticks/tests.rs | 102 ++++--- .../src/db/tuner/ticks/tests/real_data.rs | 202 +++++++++++++- crates/moon-core/src/db/tuner/ticks/verify.rs | 16 +- 10 files changed, 863 insertions(+), 203 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate.rs b/crates/moon-core/src/db/tuner/ticks/calibrate.rs index f6fa943a..1539d4f4 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate.rs @@ -50,8 +50,34 @@ pub fn step_lag_samples(deal: &Deal, exit: &ExitParams, exit_points: &[(i64, f64 .collect() } -/// A core's step lag: the median of its samples — the mean of the two middle ones for an even -/// count — or `None` below [`MIN_STEP_LAG_SAMPLES`]. +/// One archived line's replace round trips: for every re-place, how long the exchange took to +/// answer the core's request, in milliseconds. +/// +/// The core files a re-place as three points (the core developer, 2026-09-23, and every archived +/// MoonShot entry line of that day): the old level's end at the exchange's RESPONSE, the new +/// level's start at the REQUEST — earlier — and the new level again at the response, `(t_resp, +/// old)`, `(t_req, new)`, `(t_resp, new)`, on the core's clock. The round trip is `t_resp − +/// t_req`: while it runs the old order stands on the book and fills. A triple of any other shape +/// is not a re-place and is left out. +/// +/// Args: +/// points: The archived line's `(t_ms, price)` points, in the archive's order. +pub fn replace_round_trip_samples(points: &[(i64, f64)]) -> Vec { + let same = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(b.abs()); + points + .windows(3) + .filter_map(|w| { + let [(t_resp, old), (t_req, new), (t_again, again)] = [w[0], w[1], w[2]]; + (!same(old, new) && same(new, again) && t_req < t_resp && t_again == t_resp) + .then_some(t_resp - t_req) + }) + .collect() +} + +/// A core's lag: the median of its samples — the mean of the two middle ones for an even +/// count — or `None` below [`MIN_STEP_LAG_SAMPLES`]. Both the PriceDown step lag +/// ([`step_lag_samples`]) and the replace round trip ([`replace_round_trip_samples`]) are read +/// through it. /// /// Args: /// samples: Every sample of the core's deals, in any order; sorted in place. diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs index 77eb3ee1..31913d94 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs @@ -98,6 +98,27 @@ fn a_line_without_price_down_gives_nothing() { assert!(step_lag_samples(&deal(), &ExitParams::default(), &archived).is_empty()); } +/// A re-place is filed as the old level's end at the answer, the new level's start at the +/// request, and the new level again at the answer: the round trip is the answer less the +/// request. Any other shape — a plain step, a point that repeats — is no re-place. +#[test] +fn the_replace_round_trip_reads_the_request_and_the_answer() { + // FATCOIN on GateF, 2026-09-23: requested at 23 940, answered at 23 972. + let line = [ + (0, 0.00191), + (23_972, 0.00191), + (23_940, 0.00189), + (23_972, 0.00189), + (29_347, 0.00189), + (29_332, 0.00185), + (29_347, 0.00185), + ]; + assert_eq!(replace_round_trip_samples(&line), vec![32, 15]); + // A line of plain steps, each at its own moment, holds none. + let steps = [(0, 101.0), (1_000, 100.9), (2_000, 100.8)]; + assert!(replace_round_trip_samples(&steps).is_empty()); +} + /// The median, and nothing from too few samples to trust. #[test] fn the_core_lag_is_the_median_of_enough_samples() { diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index 5fa48907..b3aae327 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -111,14 +111,16 @@ pub struct ExitParams { pub stop_loss_pct: f64, pub stop_loss_delay_s: f64, /// `FastStopLoss` — what the stop watches. YES: the trades ("crosses", FAQ), so the first - /// print through the level fires it. NO — the core's default: the book's BID (the ASK for a - /// short), averaged over `StopLossEMA` of its own samples, which the trade tape does not + /// print through the level fires it. NO — the core's default: the REST ticker's BID (the + /// ASK for a short), a long's averaged per `StopLossEMA`, which the trade tape does not /// carry; the walk then reads a sampled proxy of it (see [`super::line`]). pub fast_stop_loss: bool, - /// `StopLossEMA` — how many of the core's samples the non-fast stop averages (FAQ: 0 off, - /// 3/5/10 "the last 3, 5, 10 ticks", so that a single spike through the line does not start - /// the panic sell). Ignored by a fast stop — the FAQ's own distinction, and the live - /// activations agree: 48 fast stops with it at 3 fire as promptly as 81 without it. + /// `StopLossEMA` — the non-fast stop's average of the ticker's BID, `(avg·(N − 1) + bid)/N` + /// per arrival, kept for a LONG at 3, 5 or 10 only; any other value and every short watch the + /// bare price, and at 0 the core's price series fires it too (the core developer, + /// 2026-09-23; see `line::stop_average_weight`). Ignored by a fast stop — the FAQ's own + /// distinction, and the live activations agree: 48 fast stops with it at 3 fire as promptly + /// as 81 without it. pub stop_loss_ema: f64, /// A sell rule the strategy switched on that the model does not have. The walk runs as if /// it were off, and the verdict answers nothing for such a trade, which keeps it out of the @@ -229,23 +231,29 @@ impl<'a> ExitModel<'a> { // (`PriceDownAllowedDrop`, a negative `SellShotDistance`), and they get there by // stepping down from the take, not by starting underneath it. let pct = (self.base_take_pct(deal) + self.modifier_pct(deal, fill.t_ms)).max(0.0); - let by_pct = fill.price * pct / 100.0; + let mshot = take_model_for(&deal.kind); let mut take = if deal.is_long() { - fill.price + by_pct + fill.price * (1.0 + pct / 100.0) + } else if mshot { + // The core divides a short MoonShot's take off the fill (the core developer, + // 2026-09-23): `fill / (1 + SellPrice/100)`. The two archived short takes that + // SellPrice placed and whose price step tells the formulas apart (ONE, BCH_RP) sit + // on it; MoonHook's stored take is rounded too coarsely to tell, and keeps the + // product. + fill.price / (1.0 + pct / 100.0) } else { - fill.price - by_pct + fill.price * (1.0 - pct / 100.0) }; if self.params.sell_at_last_price { let pre = deal .pre_spike_ask .filter(|p| p.is_finite() && *p > 0.0) .or_else(|| pre_spike_price(ticks, fill.t_ms)); - if let Some(pre) = pre { - let adjust = pre * self.params.sell_price_adjust_pct / 100.0; + if let (Some(pre), Some(factor)) = (pre, ask_take_factor(self.params, deal.is_short)) { take = if deal.is_long() { - take.max(pre - adjust) + take.max(pre * factor) } else { - take.min(pre + adjust) + take.min(pre * factor) }; } } @@ -428,17 +436,31 @@ pub fn archived_take(exit_points: Option<&[(i64, f64)]>) -> Option { (take.is_finite() && take > 0.0).then_some(take) } +/// What `MShotSellAtLastPrice` multiplies the ask by to place the take: `1 − adjust/100` for a +/// long, `1/(1 − adjust/100)` for a short, whose take sits below the entry and is adjusted UP +/// toward it (the core developer, 2026-09-23: `max(Y·(1 − adj/100), …)` and +/// `min(Y/(1 − adj/100), …)`). `None` for an adjustment of 100 % or more, which leaves no price. +pub fn ask_take_factor(params: &ExitParams, is_short: bool) -> Option { + let keep = 1.0 - params.sell_price_adjust_pct / 100.0; + // NaN included: an adjustment the strategy did not spell as a number leaves no price. + if keep.is_nan() || keep <= 0.0 { + return None; + } + Some(if is_short { 1.0 / keep } else { keep }) +} + /// The pre-spike ask behind an archived Exit line: its first point is the take as the core -/// placed it, `ask · (1 − MShotSellPriceAdjust/100)` when `MShotSellAtLastPrice` lifted it — -/// `ask · (1 + adjust)` for a short, whose take sits below the entry and is adjusted UP toward -/// it — so the ask is that point with the trade's own adjustment divided out. `None` when the -/// rule was off (the take came from `SellPrice`, and the archive says nothing about the ask), -/// when the archive holds no Exit line, or when the first point is not a price. +/// placed it, the ask times [`ask_take_factor`] when `MShotSellAtLastPrice` placed it, so the +/// ask is that point with the factor divided out. The ask's branch carries no delta modifier +/// (the core developer, 2026-09-23), so the ask read back is the core's own, to the price step. +/// `None` when the rule was off (the take came from `SellPrice`, and the archive says nothing +/// about the ask), when the archive holds no Exit line, or when the first point is not a price. /// -/// When `SellPrice` alone set the take higher than the ask would have, the division reads a +/// When `SellPrice` alone set the take farther than the ask would have, the division reads a /// slightly high ask back — and the same `max` (a long) or `min` (a short, whose take sits /// below the entry) puts the take on `SellPrice` again, so the trade's own replay is exact -/// either way; a variant with a smaller adjustment inherits the overread. +/// either way; a variant with a smaller adjustment inherits the overread. On the live sample +/// (2026-09-23) the ask placed 776 of 785 archived MoonShot takes. /// /// Args: /// exit_points: The archived Exit line's `(t_ms, price)` points, in the archive's order. @@ -452,14 +474,7 @@ pub fn archived_pre_spike_ask( if !params.sell_at_last_price { return None; } - let factor = if is_short { - 1.0 + params.sell_price_adjust_pct / 100.0 - } else { - 1.0 - params.sell_price_adjust_pct / 100.0 - }; - if !(factor > 0.0) { - return None; - } + let factor = ask_take_factor(params, is_short)?; let (_, take) = exit_points?.first().copied()?; (take.is_finite() && take > 0.0).then_some(take / factor) } diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs index ab811bfd..7f410338 100644 --- a/crates/moon-core/src/db/tuner/ticks/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/line.rs @@ -30,11 +30,13 @@ //! - **StopLoss** — `StopLoss` per cent from the buy (negative: a loss), armed //! `StopLossDelay` seconds after the buy. With `FastStopLoss` the first print through it is //! a market exit at the print's own price. Without it — the core's default — the core -//! watches the book's BID (a short's ASK) averaged over `StopLossEMA` samples, and the walk -//! reads a proxy of that: the last print on that side of the book (a taker sell prints at the -//! BID), sampled every [`STOP_SAMPLE_MS`], averaged the same way; the exit is at the sample, -//! at the proxy's price. Where the core's panic sell then fills is the book's business — see -//! [`super::verify`] for how the fact is judged. +//! watches the REST ticker's BID (a short's ASK), a long's averaged over `StopLossEMA` of the +//! ticker's arrivals, and the walk reads a proxy of that: the last print on that side of the +//! book (a taker sell prints at the BID), sampled every [`TICKER_PERIOD_MS`], averaged the +//! same way from before the fill on; at `StopLossEMA` 0 the core's price series fires it too +//! ([`SERIES_TICK_MS`]). The exit is at the sample, at the proxy's price. Where the core's +//! panic sell then fills is the book's business — see [`super::verify`] for how the fact is +//! judged. //! //! Every rule is written for a long and mirrored for a short by [`Side`]. A replacement reaches //! the exchange `latency_ms` later, as the entry's does: a spike through the OLD level in that @@ -56,16 +58,22 @@ use crate::feed::types::Tick; /// The terminal's own floor on a step delay of zero: the FAQ's "0.33 s internal minimum". pub const STEP_FLOOR_MS: i64 = 330; -/// How often the non-fast stop's BID proxy is sampled. The core's own cadence is not in the -/// FAQ and the tape has no book, so this is a CALIBRATION, not the core's constant: against -/// the activation the order archive records (the sell line's jump past the stop), on 199 live -/// book-watching stops (2026-09-22), the first print through the level fired a median 3.9 s -/// early with `StopLossEMA` at 3 and 0.6 s with it off; sampling the proxy every 2 s and -/// averaging the samples brings both medians within 0.4 s and puts 64 of 98 (EMA off) and 35 -/// of 101 (EMA 3) within a second, against 53 and 25. Faster sampling left the EMA-3 stops -/// seconds early, 3 s left the rest a second late. What the proxy still cannot see is the book -/// itself, and the EMA-3 stops are where that shows. -pub const STOP_SAMPLE_MS: i64 = 2_000; +/// How often the core's REST ticker brings the BID (a short's ASK) the non-fast stop watches, +/// and so how often the walk samples its proxy. The core developer (2026-09-23): the stop reads +/// the ticker, not the book and not the trades; the ticker arrives every ~2–2.3 s (Gate's spot +/// half a second slower, about once a second around each hour's turn); the stop is checked +/// every ~0.1 s but the price only moves with the ticker, so it fires on the first arrival +/// that puts it past the level. The middle of that range: over the 192 live book-watching +/// stops of 2026-09-23, 2 000–2 300 ms move the stops judged on time by ±5, the noise of a +/// phase no record keeps. +pub const TICKER_PERIOD_MS: i64 = 2_150; + +/// The core's price-series tick: one point per 250 ms, of the prints the tick brought the one +/// closest to the previous point (`docs-internal/STRATEGY_FORMULAS/deltas.md` §7). A stop at +/// `StopLossEMA` 0 fires on that series as well as on the ticker's price (the core developer, +/// 2026-09-23) — a lone print in its tick is a point, a spike among prints near the last +/// point is not. +pub const SERIES_TICK_MS: i64 = 250; /// How far past `PumpMoveTimer` the core's pump move lands, less the model's own placement /// latency: over 32 archived PumpsDetection lines (2026-09-22, every live Pump strategy runs @@ -131,39 +139,150 @@ impl Side { } } -/// The book-watching stop's state: a BID proxy — the last print on the stop's side of the -/// book — sampled every [`STOP_SAMPLE_MS`] and averaged over `StopLossEMA` samples. +/// The weight of a new ticker price in the average the non-fast stop watches, when the core +/// keeps one: `avg = (avg·(N − 1) + bid) / N`, a weight of `1/N`, for a LONG at `StopLossEMA` +/// 3, 5 or 10 only. Any other value — 7 included — and every short watch the bare price (the +/// core developer, 2026-09-23). The FAQ's "average over the last 3, 5, 10 ticks" read as an +/// EMA of `2/(N + 1)` forgot the price before a dump twice as fast, and fired early. +fn stop_average_weight(params: &ExitParams, long: bool) -> Option { + let n = params.stop_loss_ema; + let whole = (n - n.round()).abs() < 1e-9; + (long && whole && matches!(n.round() as i64, 3 | 5 | 10)).then(|| 1.0 / n) +} + +/// The book-watching stop's state: a ticker-price proxy — the last print on the stop's side of +/// the book — sampled every [`TICKER_PERIOD_MS`] and, for a long at `StopLossEMA` 3, 5 or 10, +/// averaged ([`stop_average_weight`]); at `StopLossEMA` 0 the core's price series as well. +/// +/// The core keeps the average for every market from its start, so at the fill it is warm: +/// after a dump it still remembers the prices above, and fires later than a fresh one. The +/// walk feeds it the prints before the fill for that — they move the average and can never +/// fire it. struct BookStop { long: bool, level: f64, /// The end of `StopLossDelay`: a sample before it is averaged but cannot fire. armed_at: i64, - /// The EMA weight, `2 / (StopLossEMA + 1)`; 1 without averaging. - alpha: f64, + /// The fill: a sample or a series point up to it only warms the state. + fill_ms: i64, + /// The average's weight, `None` for the bare price. + weight: Option, proxy: Option, avg: Option, next_sample: i64, /// Up to when the fact proves this stop did not fire (`record::StopAnchor`): a sample by /// then is averaged but cannot fire. `i64::MIN` when the walk is not the trade's own stop. quiet_until: i64, + /// The price series a stop at `StopLossEMA` 0 also fires on. + series: Option, +} + +/// The core's price series as the stop reads it (see [`SERIES_TICK_MS`]). +struct SeriesPoint { + /// The series' last point. + point: Option, + /// Of the prints the open tick brought, the one closest to `point`. + candidate: Option, + /// When the open tick closes; every pending print is before it. + tick_end: i64, +} + +impl SeriesPoint { + fn new(first_ms: i64) -> Self { + Self { + point: None, + candidate: None, + tick_end: next_series_tick(first_ms), + } + } + + /// Read a print into the open tick. + fn see(&mut self, price: f64) { + self.candidate = match (self.point, self.candidate) { + (Some(point), Some(held)) if (held - point).abs() <= (price - point).abs() => { + Some(held) + } + // Before the first point any print will do; the latest stands. + _ => Some(price), + }; + } +} + +/// The end of the series tick a print at `t_ms` falls in: the ticks run on the clock's 250 ms +/// boundaries, and a print ON a boundary opens the next tick. +fn next_series_tick(t_ms: i64) -> i64 { + (t_ms.div_euclid(SERIES_TICK_MS) + 1) * SERIES_TICK_MS } impl BookStop { - /// Take every sample due strictly before `until` — the prints before it are all the - /// proxy has seen — and answer the first one whose average is past the level: the stop, - /// at the sample's moment and the proxy's price. - fn sample_before(&mut self, until: i64) -> Option { + /// Args: + /// long: The trade's side. + /// level: The stop level. + /// armed_at: The end of `StopLossDelay`. + /// fill_ms: The fill. + /// first_ms: The first print the walk will feed, before the fill when the tape reaches + /// back: the ticker's clock is run back to it so the average is warm at the fill. + /// params: The sell parameters, for `StopLossEMA`. + /// quiet_until: See the field. + fn new( + long: bool, + level: f64, + armed_at: i64, + fill_ms: i64, + first_ms: i64, + params: &ExitParams, + quiet_until: i64, + ) -> Self { + // One period past the fill, and back from there in whole periods: the ticker's phase + // is on no record, so the fill anchors it, however far back the tape reaches. + let anchor = fill_ms + TICKER_PERIOD_MS; + let back = (anchor - first_ms).max(0) / TICKER_PERIOD_MS; + Self { + long, + level, + armed_at, + fill_ms, + weight: stop_average_weight(params, long), + proxy: None, + avg: None, + next_sample: anchor - back * TICKER_PERIOD_MS, + quiet_until, + series: (params.stop_loss_ema.abs() < 1e-9).then(|| SeriesPoint::new(first_ms)), + } + } + + fn may_fire(&self, at: i64) -> bool { + at > self.fill_ms && at >= self.armed_at && at > self.quiet_until + } + + /// Everything due before the print at `until` — the ticker's arrivals strictly before it, + /// the series ticks closing at or before it — and the earliest that put the stop past its + /// level: the stop, at that moment and that price. + fn before(&mut self, until: i64) -> Option { + let by_ticker = self.samples_before(until); + let by_series = self.series_before(until); + match (by_ticker, by_series) { + (Some(t), Some(s)) => Some(if s.t_ms < t.t_ms { s } else { t }), + (t, s) => t.or(s), + } + } + + /// The ticker's arrivals strictly before `until` — the prints before it are all the proxy + /// has seen — and the first whose price (averaged, when the core averages) is past the + /// level. + fn samples_before(&mut self, until: i64) -> Option { while self.next_sample < until { let at = self.next_sample; - self.next_sample += STOP_SAMPLE_MS; + self.next_sample += TICKER_PERIOD_MS; let Some(bid) = self.proxy else { continue; }; - let avg = self - .avg - .map_or(bid, |a| self.alpha * bid + (1.0 - self.alpha) * a); + let avg = match (self.weight, self.avg) { + (Some(w), Some(avg)) => w * bid + (1.0 - w) * avg, + _ => bid, + }; self.avg = Some(avg); - if at >= self.armed_at && at > self.quiet_until && reaches(avg, self.level, self.long) { + if self.may_fire(at) && reaches(avg, self.level, self.long) { return Some(Exit { t_ms: at, price: bid, @@ -174,16 +293,45 @@ impl BookStop { None } - /// Read a print into the proxy: a taker sell prints at the BID — a long's stop side; a - /// short's stop watches the ASK, where a taker buy prints. + /// The series tick the pending prints fall in, when it closes by `until`: its point, and + /// the stop when that point is strictly past the level. The ticks after it up to `until` + /// brought no print and add no point. + fn series_before(&mut self, until: i64) -> Option { + let series = self.series.as_mut()?; + if series.tick_end > until { + return None; + } + let at = series.tick_end; + series.tick_end = next_series_tick(until); + let point = series.candidate.take()?; + series.point = Some(point); + let past = if self.long { + point < self.level + } else { + point > self.level + }; + (past && self.may_fire(at)).then_some(Exit { + t_ms: at, + price: point, + kind: ExitKind::Stop, + }) + } + + /// Read a print: into the series, and into the proxy when it hit the stop's side — a taker + /// sell prints at the BID, a long's stop side; a short's stop watches the ASK, where a + /// taker buy prints. fn see(&mut self, tick: &Tick) { + let price = f64::from(tick.price); + if let Some(series) = self.series.as_mut() { + series.see(price); + } let stop_side = if self.long { crate::feed::types::Side::Sell } else { crate::feed::types::Side::Buy }; if tick.side == stop_side { - self.proxy = Some(f64::from(tick.price)); + self.proxy = Some(price); } } } @@ -381,18 +529,32 @@ pub fn walk_held( let anchor = deal.stop_anchor.filter(|a| a.holds(deal, fill, params)); let quiet_until = anchor.map_or(i64::MIN, |a| a.quiet_until_ms); let fired = anchor.and_then(|a| a.fired); - // The non-fast stop's BID proxy: the last print on the stop's side of the book, sampled on - // its own clock and averaged over `StopLossEMA` samples (see `STOP_SAMPLE_MS`). + // The non-fast stop's ticker proxy: the last print on the stop's side of the book, sampled + // on the ticker's clock, averaged as the core averages (see `BookStop`) — warmed on the + // prints before the fill, which can never fire it. let book_stop = stop_on && !params.fast_stop_loss; - let mut book = book_stop.then(|| BookStop { - long: side.long, - level: stop_level, - armed_at: stop_from, - alpha: 2.0 / (params.stop_loss_ema.max(1.0) + 1.0), - proxy: None, - avg: None, - next_sample: fill.t_ms + STOP_SAMPLE_MS, - quiet_until, + let mut book = book_stop.then(|| { + let first_ms = ticks + .first() + .map_or(fill.t_ms, |t| (t.time_ms as i64).min(fill.t_ms)); + let mut book = BookStop::new( + side.long, + stop_level, + stop_from, + fill.t_ms, + first_ms, + params, + quiet_until, + ); + for tick in ticks + .iter() + .take_while(|t| (t.time_ms as i64) <= fill.t_ms) + .filter(|t| t.price.is_finite() && t.price > 0.0) + { + let _warm_only = book.before(tick.time_ms as i64); + book.see(tick); + } + book }); let anchored_stop = |at: i64, price: f64, points: Vec| LineWalk { exit: Exit { @@ -502,10 +664,11 @@ pub fn walk_held( pending = None; } // The book-watching stop samples between prints: every sample due BEFORE this print - // reads the proxy the earlier prints left, and one past the level fires at its own - // moment, ahead of anything this print does. + // reads the proxy the earlier prints left, every series tick closing by it the points + // they left, and one past the level fires at its own moment, ahead of anything this + // print does. if let Some(book) = book.as_mut() { - if let Some(exit) = book.sample_before(t_ms) { + if let Some(exit) = book.before(t_ms) { return LineWalk { exit, points }; } book.see(tick); @@ -613,7 +776,7 @@ pub fn walk_held( } // The book stop's samples up to the tape's end — the one AT the last print included — read // the proxy the last prints left; the loop only ever reaches the samples before a print. - if let Some(exit) = book.as_mut().and_then(|book| book.sample_before(tail + 1)) { + if let Some(exit) = book.as_mut().and_then(|book| book.before(tail + 1)) { return LineWalk { exit, points }; } // Nothing closed it inside the tape. Not the report's own exit: a variant that never diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index 60955a9a..9c9ad760 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -107,9 +107,37 @@ fn the_archived_ask_sets_the_take_where_the_core_placed_it() { None ); assert_eq!(archived_pre_spike_ask(None, &p, false), None); - // A short's take is the ask adjusted UP toward the entry: divide the other way. + // A short's take is the ask adjusted UP toward the entry, `ask / (1 − 0.2 %)`: the ask is + // the take times 0.998. let short_ask = archived_pre_spike_ask(Some(&[(0, 0.031603)]), &p, true).expect("on"); - assert!((short_ask - 0.031603 / 1.002).abs() < 1e-12); + assert!((short_ask - 0.031603 * 0.998).abs() < 1e-12); +} + +/// A short MoonShot's take is divided off the fill, `fill / (1 + SellPrice/100)`, and the ask's +/// branch placed at `ask / (1 − adjust/100)` when it is the lower (the core developer, +/// 2026-09-23; ONE and BCH_RP on the archive). +#[test] +fn a_short_moonshot_take_divides_off_the_fill() { + let p = ExitParams { + sell_price_pct: 1.0, + ..params() + }; + let take = ExitModel::new(&p).take_level(&deal(true), &[], fill()); + assert!((take - 100.0 / 1.01).abs() < 1e-9, "{take}"); + let lifted = ExitParams { + sell_at_last_price: true, + sell_price_adjust_pct: 1.0, + ..p + }; + let mut d = deal(true); + d.pre_spike_ask = Some(97.0); + let take = ExitModel::new(&lifted).take_level(&d, &[], fill()); + assert!((take - 97.0 / 0.99).abs() < 1e-9, "{take}"); + // A MoonHook short keeps the product: its stored take cannot tell the two apart. + let mut hook = deal(true); + hook.kind = "MoonHook".into(); + let take = ExitModel::new(&p).take_level(&hook, &[], fill()); + assert!((take - 99.0).abs() < 1e-9, "{take}"); } // ---- PriceDown ------------------------------------------------------------------------------- @@ -362,17 +390,24 @@ fn sold(t_ms: i64, price: f64) -> Tick { } } -/// The book-watching stop (`FastStopLoss` off) reads the BID through the prints that hit it — -/// taker sells — on its own sample clock, not every print through the level. -#[test] -fn the_book_stop_fires_on_a_sample_of_the_bid_not_on_a_print() { - let book = ExitParams { +/// A book-watching stop (`FastStopLoss` off) that reads the bare ticker price: `StopLossEMA` +/// neither 0 (which adds the series) nor 3, 5, 10 (which average). +fn bare_book_stop() -> ExitParams { + ExitParams { stop_loss_pct: -1.0, fast_stop_loss: false, + stop_loss_ema: 7.0, ..params() - }; + } +} + +/// The book-watching stop (`FastStopLoss` off) reads the ticker's BID through the prints that +/// hit it — taker sells — on the ticker's clock, not every print through the level. +#[test] +fn the_book_stop_fires_on_a_sample_of_the_bid_not_on_a_print() { + let book = bare_book_stop(); // A taker BUY through the level says nothing about the BID; the taker sell at 98.8 does, - // and the next sample after it — 4 s — fires, at the proxy's price. + // and the next arrival after it — 4.3 s — fires, at the proxy's price. let ticks = vec![ tick(1_000, 98.5), sold(1_500, 99.5), @@ -380,7 +415,10 @@ fn the_book_stop_fires_on_a_sample_of_the_bid_not_on_a_print() { tick(5_000, 100.0), ]; let w = walk(&deal(false), &ticks, fill(), 101.0, &book); - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 4_000)); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, 2 * TICKER_PERIOD_MS) + ); assert!((w.exit.price - 98.8).abs() < 1e-4); // The fast stop takes the first print through the level, whichever side it hit. let fast = ExitParams { @@ -395,38 +433,122 @@ fn the_book_stop_fires_on_a_sample_of_the_bid_not_on_a_print() { /// samples before a print, so the tape's end is where it must not be forgotten. #[test] fn the_book_stop_takes_the_sample_at_the_last_print() { - let book = ExitParams { - stop_loss_pct: -1.0, - fast_stop_loss: false, - ..params() - }; - let w = walk(&deal(false), &[sold(2_000, 98.8)], fill(), 101.0, &book); - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 2_000)); + let book = bare_book_stop(); + let w = walk( + &deal(false), + &[sold(TICKER_PERIOD_MS, 98.8)], + fill(), + 101.0, + &book, + ); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, TICKER_PERIOD_MS) + ); // A sample the tape ends before is not taken: nothing is known past the last print. let w = walk(&deal(false), &[sold(1_500, 98.8)], fill(), 101.0, &book); assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd); } -/// `StopLossEMA` averages the samples, so a BID just past the level fires only once the -/// average is past it too. +/// `StopLossEMA` 3 averages the ticker's arrivals as the core does, `(avg·2 + bid)/3`, so a BID +/// just past the level fires only once the average is past it too. #[test] fn the_stop_ema_waits_for_the_average() { - let ticks = vec![sold(1_500, 99.5), sold(2_500, 98.9), sold(9_000, 98.9)]; - let plain = ExitParams { - stop_loss_pct: -1.0, - fast_stop_loss: false, - ..params() - }; - let w = walk(&deal(false), &ticks, fill(), 101.0, &plain); - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 4_000)); - // Samples 99.5, 98.9, 98.9, 98.9 at 2, 4, 6, 8 s: the EMA over 3 (α = 0.5) reads 99.5, - // 99.2, 99.05, 98.975 — past 99 at the fourth. + let ticks = vec![ + sold(1_500, 99.5), + sold(2_500, 98.9), + sold(9_000, 98.9), + sold(13_000, 98.9), + ]; + let bare = bare_book_stop(); + let w = walk(&deal(false), &ticks, fill(), 101.0, &bare); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, 2 * TICKER_PERIOD_MS) + ); + // Arrivals 99.5, then 98.9 from the second on: the average reads 99.5, 99.3, 99.167, + // 99.078, 99.019, 98.979 — past 99 at the sixth. An EMA of 2/(N + 1) = 0.5 would have + // crossed at the fourth. let smoothed = ExitParams { stop_loss_ema: 3.0, - ..plain + ..bare }; let w = walk(&deal(false), &ticks, fill(), 101.0, &smoothed); - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 8_000)); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, 6 * TICKER_PERIOD_MS) + ); +} + +/// The core keeps the average from its start, so at the fill it remembers the prices before it: +/// the prints before the fill warm it, and can never fire it. +#[test] +fn the_stop_average_is_warm_at_the_fill() { + let smoothed = ExitParams { + stop_loss_ema: 3.0, + ..bare_book_stop() + }; + let after = [sold(1_500, 98.9), sold(30_000, 98.9)]; + let cold = walk(&deal(false), &after, fill(), 101.0, &smoothed); + assert_eq!( + (cold.exit.kind, cold.exit.t_ms), + (ExitKind::Stop, TICKER_PERIOD_MS) + ); + // A minute at 101 before the fill: the average starts there, seven arrivals of 98.9 leave + // it at 101 · (2/3)^7 + 98.9 · (1 − (2/3)^7) = 99.02, and the eighth brings it to 98.98. + let mut warm: Vec = (0..30).map(|i| sold(-60_000 + i * 2_000, 101.0)).collect(); + warm.extend(after); + let w = walk(&deal(false), &warm, fill(), 101.0, &smoothed); + assert_eq!(w.exit.kind, ExitKind::Stop); + assert_eq!(w.exit.t_ms, 8 * TICKER_PERIOD_MS, "{w:?}"); + // The prints before the fill, past the level, fire nothing before it. + let early = vec![sold(-3_000, 98.0), tick(500, 100.0)]; + let w = walk(&deal(false), &early, fill(), 101.0, &bare_book_stop()); + assert!(w.exit.t_ms > 0, "{w:?}"); +} + +/// A short's book stop watches the bare ASK: `StopLossEMA` averages a long's BID only. +#[test] +fn a_short_book_stop_does_not_average() { + let smoothed = ExitParams { + stop_loss_ema: 3.0, + ..bare_book_stop() + }; + // A taker buy prints at the ASK: 100 before the fill, then 101.1 past a short's stop at 101 + // — which an average of the two, 100.37, would not be. + let ticks = vec![tick(-1_000, 100.0), tick(1_500, 101.1), tick(5_000, 100.0)]; + let w = walk(&deal(true), &ticks, fill(), 99.0, &smoothed); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, TICKER_PERIOD_MS) + ); +} + +/// At `StopLossEMA` 0 the core's price series fires the stop too: a lone print past the level +/// is its tick's point, and fires when the tick closes; a spike among prints near the last +/// point is not the point, and waits for the ticker. +#[test] +fn a_stop_without_averaging_fires_on_the_series_point() { + let series = ExitParams { + stop_loss_ema: 0.0, + ..bare_book_stop() + }; + let lone = vec![tick(1_000, 98.5), tick(5_000, 100.0)]; + let w = walk(&deal(false), &lone, fill(), 101.0, &series); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 1_250)); + assert!((w.exit.price - 98.5).abs() < 1e-4); + // The same print after a point at 100 and beside 99.9 in its tick: the point is 99.9. + let spike = vec![ + tick(900, 100.0), + tick(1_000, 98.5), + tick(1_100, 99.9), + tick(5_000, 100.0), + ]; + let w = walk(&deal(false), &spike, fill(), 101.0, &series); + assert_ne!(w.exit.kind, ExitKind::Stop, "{w:?}"); + // At 7 the core reads the bare ticker price and no series. + let w = walk(&deal(false), &lone, fill(), 101.0, &bare_book_stop()); + assert_ne!(w.exit.kind, ExitKind::Stop, "{w:?}"); } /// A book stop's sale is a panic sell walked through the book: the verdict holds the model's diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index 3395889c..0fe5d83f 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -11,7 +11,8 @@ //! ORDER prices symmetric around the placement, from `MShotPriceMin` to `2 · MShotPrice − //! MShotPriceMin` off the reference, modifiers included — to 0.05 % on 155 of 287 MoonShot //! trades (2026-09-23), the rest wider by what the live deltas moved. So the order is re-placed -//! only past `2 · far − near`, not past `far`; +//! only past `far + min(near, far − near)` ([`retreat_pct`], the core developer's rule, the +//! same band wherever `near ≥ far / 2`), not past `far`; //! - every `MShotAdd*Delta` adds `k · delta` to BOTH bounds, the delta's sign as the report //! carries it: the FAQ's `-10% + (-20 · 0.05) = -11%` is a coin UP 20 % on 3 h putting the //! order 1 % deeper (checked on the live tape on 2026-09-20: a long on ROSE, up 7 % / 11 % on @@ -20,10 +21,17 @@ //! `MShotAddDistance` scales what the FAR bound receives by `1 + distance / 100`; //! - `MShotMinusSatoshi` keeps the order at least two price steps off the reference; //! - `MShotUsePrice` picks the reference: the last trade, or the book's ASK / BID; -//! - `FastShotAlgo` with a non-zero `MShotRaiseWait` is the FAQ's "algorithm 2": the reference -//! is the lowest print of the last 100 ms (highest, for a short), which is what keeps the -//! order from bouncing back up on a single print. With a zero wait it is "algorithm 1", a -//! price "over the last few trades" the FAQ does not size; the model reads the last print. +//! - the corridor is measured from the current price, and a re-placed order is put off the +//! lowest print of the last 100 ms (highest, for a short) — for every `MShotRaiseWait`, which +//! is what keeps the order from being placed off a spike's rebound (the core developer, +//! 2026-09-23; the FAQ gives the window to `FastShotAlgo`'s "algorithm 2" alone). See +//! [`Reference`]. The core re-places one order at a time — the next only once the exchange +//! answered the last — and checks the corridor on a timer, 16 ms with `FastShotAlgo` and 80 ms +//! without; the model decides on the prints and folds the timer into `latency_ms`. Stepping the +//! timer, and `FastShotAlgo = NO`'s own run-away rule and 100 ms sleep, moved the live sample by +//! −1…−4 entries of 823 (2026-09-23) while the corridor's LEVEL was still off by 0.2 % on the +//! median — `MShotAddMarkDelta` reads a mark price with no history here — and are left out +//! until the level can tell them apart. //! //! Two things the tape cannot give and the model states instead: the book (an ASK / BID //! reference is approximated by the last buy-side / sell-side print), and the time a @@ -176,8 +184,10 @@ pub const DEFAULT_LATENCY_MS: f64 = 100.0; /// The seconds the FAQ's "4-second-old ASK" of `MShotSellAtLastPrice` looks back. pub const PRE_SPIKE_LOOKBACK_MS: i64 = 4_000; -/// The window of `FastShotAlgo`'s algorithm 2: the reference is the extreme print of the last -/// 100 ms (the FAQ: "the minimum trade over 100 ms"). +/// The window a re-placed order's price is read off: the extreme print of the last 100 ms. The +/// FAQ gives it to `FastShotAlgo`'s algorithm 2 ("the minimum trade over 100 ms"); the core +/// developer (2026-09-23) to every re-place, whatever `MShotRaiseWait` — the trades of the last +/// ~75–150 ms. pub const FAST_ALGO_WINDOW_MS: i64 = 100; /// MoonShot entry parameters, in the strategy's own units (per cent, seconds). @@ -194,8 +204,10 @@ pub struct MshotParams { pub replace_delay_s: f64, /// `MShotMinusSatoshi`. pub minus_satoshi: bool, - /// `FastShotAlgo` — see the module doc; only algorithm 2 (with a non-zero raise wait) - /// changes the reference. + /// `FastShotAlgo` — the core's corridor timer (16 ms on, 80 ms off) and, off, a run-away + /// read off the last print with a 100 ms sleep after each re-place (the core developer, + /// 2026-09-23). Off on 224 of the 823 live MoonShot entries of that day; the model reads the + /// same corridor either way (see the module doc). pub fast_algo: bool, pub modifiers: Modifiers, /// Model parameter, not a strategy field: how long a replacement takes to reach the book. @@ -344,6 +356,35 @@ impl<'a> MshotEntry<'a> { ticks: &[Tick], line: Option<&[(i64, f64)]>, ) -> Option { + self.run_traced(deal, ticks, line, None) + } + + /// [`Self::run`], and every move the core would have made — `(t_ms, level)` of each + /// placement and re-place, as the core decided it — for holding the order's path against the + /// archived entry line. + pub fn trace( + &self, + deal: &Deal, + ticks: &[Tick], + line: Option<&[(i64, f64)]>, + ) -> (Option, Vec<(i64, f64)>) { + let mut moves = Vec::new(); + let fill = self.run_traced(deal, ticks, line, Some(&mut moves)); + (fill, moves) + } + + fn run_traced( + &self, + deal: &Deal, + ticks: &[Tick], + line: Option<&[(i64, f64)]>, + mut moves: Option<&mut Vec<(i64, f64)>>, + ) -> Option { + let mut note = |t_ms: i64, level: f64| { + if let Some(moves) = moves.as_deref_mut() { + moves.push((t_ms, level)); + } + }; if ticks.is_empty() { return None; } @@ -352,11 +393,7 @@ impl<'a> MshotEntry<'a> { let replace_delay_ms = (self.params.replace_delay_s * 1000.0).max(0.0); let latency_ms = self.params.latency_ms.max(0.0); - let mut reference = Reference::new( - self.params.use_price, - self.params.fast_algo && raise_wait_ms > 0.0, - deal.is_long(), - ); + let mut reference = Reference::new(self.params.use_price, deal.is_long()); let first_print_ms = ticks[0].time_ms as i64; let mut index = 0; @@ -384,6 +421,7 @@ impl<'a> MshotEntry<'a> { reference.observe(&ticks[index]); index += 1; } + note(created_ms, level); (None, level, Some((created_ms + latency_ms as i64, level))) } None => { @@ -436,13 +474,17 @@ impl<'a> MshotEntry<'a> { reference.observe(first); index += 1; let (_, far_pct) = bounds.at(first.time_ms as i64); - self.place(reference.price()?, far_pct, deal) + let level = + self.place(reference.placement(first.time_ms as i64)?, far_pct, deal); + note(first.time_ms as i64, level); + level } }; (Some(level), level, None) } }; let mut hints = hints.into_iter().peekable(); + // The breach the corridor is waiting out: which way, and since when. let mut breach: Option<(Breach, i64)> = None; for tick in &ticks[index..] { @@ -457,6 +499,7 @@ impl<'a> MshotEntry<'a> { core_level = level; pending = Some((hint_ms + latency_ms as i64, level)); breach = None; + note(hint_ms, level); } if let Some((_, level)) = pending.filter(|(apply_at, _)| t_ms >= *apply_at) { exch_level = Some(level); @@ -466,15 +509,12 @@ impl<'a> MshotEntry<'a> { return Some(Fill { t_ms, price: level }); } reference.observe(tick); - let Some(reference) = reference.price() else { + let Some(check) = reference.check() else { continue; }; let (near_pct, far_pct) = bounds.at(t_ms); - // The corridor's far edge: a run-away re-places the order only past it (module doc). - // Where the bounds meet after the modifiers (`bounds_pct` lifts far to near) the band - // has no width: every move off the placement re-places, as before. - let retreat_pct = 2.0 * far_pct - near_pct; - let distance = Self::distance_pct(reference, core_level, deal); + let retreat_pct = retreat_pct(near_pct, far_pct); + let distance = Self::distance_pct(check, core_level, deal); let now = if distance < near_pct { Some(Breach::Approach) } else if distance > retreat_pct { @@ -496,10 +536,21 @@ impl<'a> MshotEntry<'a> { Breach::Approach => replace_delay_ms, Breach::Retreat => raise_wait_ms, }; - if (t_ms - since) as f64 >= wait_ms { - core_level = self.place(reference, far_pct, deal); + // One re-place at a time: the core sends the next only once the exchange + // has answered the last — the archive files every re-place as a request and + // its answer, and none overlaps the one before (2026-09-23). A spike's prints + // inside the round trip move nothing; without the rule the model chased them + // print by print, 676 re-places the archive never shows on 143 orders + // replayed from their creation, against 389 with it. + let in_flight = pending.is_some(); + if !in_flight && (t_ms - since) as f64 >= wait_ms { + // The new level comes off the window's extreme, not off the print that + // decided the move (see `Reference`). + let placement = reference.placement(t_ms).unwrap_or(check); + core_level = self.place(placement, far_pct, deal); pending = Some((t_ms + latency_ms as i64, core_level)); breach = None; + note(t_ms, core_level); } } } @@ -544,30 +595,44 @@ impl<'a> LiveBounds<'a> { } } -/// The reference price the corridor is measured from, as the prints go by. +/// How far off the reference a run-away price leaves the order before the core re-places it: +/// `MShotPrice + min(MShotPriceMin, MShotPrice − MShotPriceMin)` (the core developer, +/// 2026-09-23). Where `near ≥ far / 2` — every one of the 292 corridors the core saved by +/// 2026-09-23 — that is `2 · far − near`, the band's far edge the saved corridor shows; a +/// variant with a narrower `near` re-places at `far + near`. Where the bounds meet after the +/// modifiers (`bounds_pct` lifts far to near) the band has no width and every move re-places. +fn retreat_pct(near_pct: f64, far_pct: f64) -> f64 { + far_pct + near_pct.min(far_pct - near_pct) +} + +/// The prices the corridor reads, as the prints go by (the core developer, 2026-09-23). /// -/// The last print of the wanted side (`MShotUsePrice`), falling back to the last print of any -/// side until one of that side has been seen; under algorithm 2 of `FastShotAlgo`, the extreme -/// of the wanted side's prints inside the last [`FAST_ALGO_WINDOW_MS`]. +/// - [`Self::check`], what the corridor is measured from — whether the price came too close or +/// ran away: the last print of the wanted side (`MShotUsePrice`), falling back to the last +/// print of any side until one of that side has been seen. The core reads the current price +/// for both, and a run-away holds for `MShotRaiseWait` exactly when the price stayed away that +/// long — the timer in [`MshotEntry::run`]; neither looks at a window. +/// - [`Self::placement`], what a re-placed order is put off: the extreme of the wanted side's +/// prints inside the last [`FAST_ALGO_WINDOW_MS`] — the lowest for a long — whatever +/// `MShotRaiseWait` is. The core takes the minimum of the trades of the last ~75–150 ms, so a +/// spike's own low prints place the order below it rather than off the rebound. struct Reference { wanted_side: Option, - fast: bool, is_long: bool, last_any: Option, last_side: Option, - /// `(t_ms, price)` of the wanted side's prints inside the fast window, oldest first. + /// `(t_ms, price)` of the wanted side's prints inside the window, oldest first. recent: std::collections::VecDeque<(i64, f64)>, } impl Reference { - fn new(use_price: UsePrice, fast: bool, is_long: bool) -> Self { + fn new(use_price: UsePrice, is_long: bool) -> Self { Self { wanted_side: match use_price { UsePrice::Trade => None, UsePrice::Ask => Some(Side::Buy), UsePrice::Bid => Some(Side::Sell), }, - fast, is_long, last_any: None, last_side: None, @@ -583,29 +648,43 @@ impl Reference { self.last_any = Some(price); if self.wanted_side.is_none_or(|s| s == tick.side) { self.last_side = Some(price); - if self.fast { - let t_ms = tick.time_ms as i64; - self.recent.push_back((t_ms, price)); - while self - .recent - .front() - .is_some_and(|(t, _)| t_ms - *t > FAST_ALGO_WINDOW_MS) - { - self.recent.pop_front(); - } + let t_ms = tick.time_ms as i64; + self.recent.push_back((t_ms, price)); + while self + .recent + .front() + .is_some_and(|(t, _)| t_ms - *t > FAST_ALGO_WINDOW_MS) + { + self.recent.pop_front(); } } } - fn price(&self) -> Option { - if self.fast && !self.recent.is_empty() { - let prices = self.recent.iter().map(|(_, p)| *p); - return if self.is_long { - prices.reduce(f64::min) - } else { - prices.reduce(f64::max) - }; - } + /// What the corridor is measured from. The core takes the lower of the last print and the + /// best bid (a short: the higher of it and the ask); the tape has no book, and the last + /// taker sell standing in for the bid lost 4 entries net on the live sample (2026-09-23) — + /// the print alone is what the tape can say. + fn check(&self) -> Option { self.last_side.or(self.last_any) } + + /// What a re-placed order is put off, deciding at `now_ms`: the extreme of the wanted side's + /// prints of the last [`FAST_ALGO_WINDOW_MS`] before it, else the check price. The window is + /// pruned only when that side prints, so it is read against the decision's own moment: a + /// burst an ASK / BID side printed seconds ago is not "the last 100 ms". Its fallback is still + /// that side's last print, however old — the tape has no book, and the corridor is measured + /// off that same print; the placement only stays consistent with it. + fn placement(&self, now_ms: i64) -> Option { + let prices = self + .recent + .iter() + .filter(|(t, _)| now_ms - *t <= FAST_ALGO_WINDOW_MS) + .map(|(_, p)| *p); + let extreme = if self.is_long { + prices.reduce(f64::min) + } else { + prices.reduce(f64::max) + }; + extreme.or_else(|| self.check()) + } } diff --git a/crates/moon-core/src/db/tuner/ticks/record/tests.rs b/crates/moon-core/src/db/tuner/ticks/record/tests.rs index 67e2f0f1..e84f9bb7 100644 --- a/crates/moon-core/src/db/tuner/ticks/record/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -184,8 +184,8 @@ fn the_stop_moment_comes_from_the_archive_then_the_close() { assert_eq!(anchor.quiet_until_ms, 3_600); } -/// The verdict tests the proxy, never the anchor: the core's activation 5.5 s after the proxy's -/// sample is a miss of the model, even though a variant would sell exactly where the core did. +/// The verdict tests the proxy, never the anchor: the core's activation 5 s after the proxy's +/// is a miss of the model, even though a variant would sell exactly where the core did. #[test] fn the_verdict_never_leans_on_the_anchor() { let mut d = stopped(); @@ -197,7 +197,7 @@ fn the_verdict_never_leans_on_the_anchor() { assert_eq!( v.exit, Some(false), - "the proxy fired at 4 s, the core at 8 s: {v:?}" + "the series fired at 2.75 s, the core at 8 s: {v:?}" ); let w = walk(&d, &ticks, fact_fill(), 101.0, &book()); assert_eq!((w.exit.t_ms, w.exit.price), (8_000, 97.0)); diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 2aa7e889..d4437aea 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -592,10 +592,11 @@ fn an_ask_reference_follows_buy_side_prints_only() { assert!((fill.price - 99.99).abs() < 1e-9, "{}", fill.price); } +/// The corridor is measured from the current print, whatever `FastShotAlgo` is; a re-placed +/// order goes off the lowest print of the last 100 ms, not off the print that decided the move +/// (the core developer, 2026-09-23). #[test] -fn fast_algo_measures_the_corridor_from_the_extreme_print_of_the_last_100_ms() { - // An 80 ms replace delay and no latency, so the move lands between prints and the two - // references can be told apart. +fn the_corridor_reads_the_last_print_and_re_places_off_the_windows_low() { let params = MshotParams { fast_algo: true, raise_wait_s: 30.0, @@ -603,15 +604,8 @@ fn fast_algo_measures_the_corridor_from_the_extreme_print_of_the_last_100_ms() { latency_ms: 0.0, ..mshot() }; - let plain = MshotParams { - fast_algo: false, - ..params.clone() - }; - // Level 99 off 100. A dip to 99.4 at t=1000 (0.40 % < 0.5 %: an approach) followed by 99.6 - // prints at t=1050 and t=1090. The plain reference is the last print, 99.6 → 0.60 %, inside - // the corridor: the approach is forgotten and 99.0 at t=1100 fills. The fast reference is - // the lowest print of the last 100 ms — still the 99.4 at t=1090 — so the approach has - // held for 90 ms ≥ 80 ms and the order moves off 99 before the 99.0 arrives. + // Level 99 off 100. A dip to 99.4 at t=1000 (0.40 % < 0.5 %: an approach) and back to 99.6 + // before the 80 ms delay ran out: the approach is forgotten, and 99.0 at t=1100 fills. let dip = tape(&[ (0, 100.0), (1_000, 99.4), @@ -619,27 +613,66 @@ fn fast_algo_measures_the_corridor_from_the_extreme_print_of_the_last_100_ms() { (1_090, 99.6), (1_100, 99.0), ]); + for fast_algo in [true, false] { + let p = MshotParams { + fast_algo, + ..params.clone() + }; + let fill = fill_of(&deal(), &dip, &p).expect("still at 99"); + assert!( + (fill.price - 99.0).abs() < 1e-9, + "fast {fast_algo}: {fill:?}" + ); + } + // An approach held 60 ms past a 50 ms delay: the order is re-placed 1 % under the window's + // low, 99.40 — not under 99.48, the print that decided the move — so 98.45 does not reach + // it, and 98.40 does. + let held = MshotParams { + replace_delay_s: 0.05, + ..params + }; + let ticks = tape(&[ + (0, 100.0), + (1_000, 99.45), + (1_020, 99.40), + (1_060, 99.48), + (1_200, 98.45), + (1_300, 98.40), + ]); + let fill = fill_of(&deal(), &ticks, &held).expect("filled"); + assert_eq!(fill.t_ms, 1_300); assert!( - fill_of(&deal(), &dip, &plain).is_some(), - "plain: still at 99" + (fill.price - f64::from(99.40_f32) * 0.99).abs() < 1e-9, + "{fill:?}" ); - assert_eq!( - fill_of(&deal(), &dip, ¶ms), - None, - "fast: moved off the 99.4" - ); - // The 99.4 falls out of the window after 100 ms: at t=1150 the reference is 99.6 again, the - // approach is forgotten, and 99.0 fills. - let back = tape(&[(0, 100.0), (1_000, 99.4), (1_150, 99.6), (1_200, 99.0)]); - let fill = fill_of(&deal(), &back, ¶ms).expect("still at 99 after the dip aged out"); - assert!((fill.price - 99.0).abs() < 1e-9); - // Without a raise wait the fast algo is algorithm 1, which the model reads as the plain - // last print. - let algo1 = MshotParams { - raise_wait_s: 0.0, - ..params +} + +/// One re-place at a time: while the last one is on its way to the exchange, a spike's prints +/// move nothing — the order the spike meets is the one the exchange has. +#[test] +fn no_re_place_while_the_last_is_in_flight() { + let params = MshotParams { + latency_ms: 300.0, + ..mshot() }; - assert!(fill_of(&deal(), &dip, &algo1).is_some()); + // Level 99 off 100, on the book at once (placed off the first print). At t=1000 the price + // runs 1.59 % away, past the 1.5 % edge: the order is re-placed off 100.6 (99.594), on the + // book at 1300. At t=1150 101.2 runs away again; the move is in flight, so nothing moves — + // chasing it would have put the order at 100.188, which 100.15 fills. The exchange's + // 99.594 is what 99.5 fills. + let ticks = tape(&[ + (0, 100.0), + (1_000, 100.6), + (1_150, 101.2), + (1_500, 100.15), + (1_600, 99.5), + ]); + let fill = fill_of(&deal(), &ticks, ¶ms).expect("filled"); + assert_eq!(fill.t_ms, 1_600); + assert!( + (fill.price - f64::from(100.6_f32) * 0.99).abs() < 1e-9, + "{fill:?}" + ); } #[test] @@ -773,16 +806,17 @@ fn sell_delay_arms_the_take_late() { assert_eq!((out.kind, out.t_ms), (ExitKind::Take, 1_700)); } +/// A short MoonShot's take sits below the fill, divided off it: `101 / (1 + 1 %)` = 100. #[test] fn a_short_take_is_below_the_fill() { let fill = Fill { t_ms: 1_000, price: 101.0, }; - let ticks = tape(&[(1_000, 101.0), (2_000, 100.0), (3_000, 99.9)]); + let ticks = tape(&[(1_000, 101.0), (2_000, 100.05), (3_000, 99.95)]); let out = ExitModel::new(&ExitParams::default()).exit(&short_deal(), &ticks, fill); assert_eq!((out.kind, out.t_ms), (ExitKind::Take, 3_000)); - assert!((out.price - 99.99).abs() < 1e-9); + assert!((out.price - 100.0).abs() < 1e-9); } // ---- simulate: the whole trade ----------------------------------------------------------- @@ -808,7 +842,9 @@ fn simulate_chains_entry_and_exit_and_signs_the_result() { &ExitParams::default(), None, ); - assert!((short.profit_pct.unwrap() - 1.0).abs() < 1e-9); + // Filled at 101, taken at 101 / 1.01 = 100: 1 − 1/1.01 of the fill. + let pct = short.profit_pct.unwrap(); + assert!((pct - 100.0 * (1.0 - 1.0 / 1.01)).abs() < 1e-9, "{pct}"); } #[test] diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index b7e780b1..f95c1ab0 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -8,7 +8,11 @@ //! coverage column uses) and its entry line from `order_traces.sqlite`, runs [`verify`] on the //! parameters as of the buy, and prints one line per deal plus the ✓ share per group. The //! environment variables are read HERE only, in a test a developer runs by hand (`MOON_TICKS_COIN` -//! narrows the run to one coin); the application never moves its data root on a variable. +//! narrows the run to one coin; `MOON_TICKS_DUMP=` writes every deal and its prints for an +//! analysis outside; `MOON_TICKS_LATENCY_MS` replays with another latency and +//! `MOON_TICKS_LATENCY_BASE_MS` with that plus each core's archived round trip, the entry's only +//! unless `MOON_TICKS_LATENCY_EXIT` is set; `MOON_TICKS_PATH_DEBUG` prints each order's modelled +//! and archived path); the application never moves its data root on a variable. use std::collections::HashMap; use std::path::PathBuf; @@ -86,8 +90,20 @@ fn dump_deal( exit_points: Option<&[(i64, f64)]>, entry_points: Option<&[(i64, f64)]>, entry: &EntryParams, + exit: &ExitParams, ) { use std::io::Write; + // The stop as the verdict reads it (`verify::verify_stop`): the model's level off the fact's + // buy, the level the core printed into the reason, and the activation — the archive's jump + // past that level. + let stop = super::super::exit::stop_pct(exit, deal, deal.buy_ms); + let level = if deal.is_long() { + deal.buy_price * (1.0 + stop / 100.0) + } else { + deal.buy_price * (1.0 - stop / 100.0) + }; + let stated = verify::stated_stop_level(&deal.sell_reason); + let activation = verify::archived_stop_jump(deal, stated.unwrap_or(level), exit_points); let dir = PathBuf::from(dir); let _ = std::fs::create_dir_all(dir.join("ticks")); let row = serde_json::json!({ @@ -111,6 +127,15 @@ fn dump_deal( "order_open_ms": deal.order_open_ms(), "entry_placed": deal.entry_placed, "corridor": deal.corridor, + "stop": { + "pct": stop, + "level": level, + "stated": stated, + "activation": activation, + "fast": exit.fast_stop_loss, + "ema": exit.stop_loss_ema, + "delay_s": exit.stop_loss_delay_s, + }, "mshot": match entry { EntryParams::MoonShot(p) => { let (near, far) = p.bounds_pct(&deal.deltas_at(deal.buy_ms)); @@ -228,6 +253,90 @@ fn core_step_lags( .collect() } +/// How much of the order's archived path the model walked: the archived moves after the creation +/// and before the fill, the ones the model also made (within a second, on the same price step), +/// and the model's moves the archive never shows. +#[derive(Default)] +struct PathTally { + deals: usize, + whole: usize, + archived: usize, + matched: usize, + extra: usize, + /// Archived moves with a model move within the second, whatever its price. + timed: usize, + /// For those, how far the nearest model move's level sat, per cent of the archived one — + /// `[0]` strategies whose `MShotAdd*` move the corridor, `[1]` those whose do not. + level_errors: [Vec; 2], +} + +impl PathTally { + fn add(&mut self, deal: &Deal, model: &[(i64, f64)], archived: &[(i64, f64)], moved: bool) { + // Within a second, and within a step or 0.05 % — the reference the core read and the + // print the model read sit a step apart on a spike. + let step = deal.tick.unwrap_or(0.0); + let same = |a: (i64, f64), b: (i64, f64)| { + (a.0 - b.0).abs() <= verify::POINT_TIME_TOLERANCE_MS + && (a.1 - b.1).abs() <= (step * 1.01).max(a.1.abs() * 5e-4) + }; + let until = deal.buy_ms; + // Past the creation's own point, and before the fill. + let archived: Vec<(i64, f64)> = verify::archived_replacements(archived) + .into_iter() + .skip(1) + .filter(|&(t, _)| t < until) + .collect(); + let model: Vec<(i64, f64)> = model + .iter() + .skip(1) + .copied() + .filter(|&(t, _)| t < until) + .collect(); + let matched = archived + .iter() + .filter(|&&a| model.iter().any(|&m| same(a, m))) + .count(); + let extra = model + .iter() + .filter(|&&m| !archived.iter().any(|&a| same(a, m))) + .count(); + for &(t, p) in &archived { + if let Some(&(_, mp)) = model + .iter() + .filter(|&&(mt, _)| (mt - t).abs() <= verify::POINT_TIME_TOLERANCE_MS) + .min_by(|a, b| (a.1 - p).abs().total_cmp(&(b.1 - p).abs())) + { + self.timed += 1; + self.level_errors[usize::from(!moved)].push((mp - p) / p * 100.0); + } + } + self.deals += 1; + self.whole += usize::from(matched == archived.len() && extra == 0); + self.archived += archived.len(); + self.matched += matched; + self.extra += extra; + } +} + +/// Each core's replace round trip off its archived Entry lines +/// (`calibrate::replace_round_trip_samples`, the median per core). +fn core_round_trips(deals: &[Deal]) -> HashMap { + let mut samples: HashMap> = HashMap::new(); + for deal in deals.iter().filter(|d| entry_model_for(&d.kind)) { + let (Some(points), _, _) = archived_lines(deal) else { + continue; + }; + samples + .entry(deal.core_uid) + .or_default() + .extend(calibrate::replace_round_trip_samples(&points)); + } + samples + .into_iter() + .filter_map(|(core, mut v)| calibrate::median_step_lag(&mut v).map(|rt| (core, rt))) + .collect() +} + fn round3(v: Option) -> Option { v.map(|d| (d * 1000.0).round() / 1000.0) } @@ -342,6 +451,13 @@ fn real_data_reproduction() { let keys = param_keys(); let defaults = HashMap::new(); let core_lags = core_step_lags(&read.deals, &keys, &defaults); + // `MOON_TICKS_LATENCY_BASE_MS=`: each core's latency is that plus its own archived + // replace round trip, instead of one number for every core. + let round_trips = core_round_trips(&read.deals); + let latency_base = std::env::var("MOON_TICKS_LATENCY_BASE_MS") + .ok() + .and_then(|v| v.parse::().ok()); + eprintln!("replace round trip per core: {round_trips:?} · base {latency_base:?}"); // The live deltas' history bars (`deltas::track_for`), off this data root's kline cache — // opened on a COPY of the data root: opening prunes past the retention, as the app does. // `MOON_TICKS_SNAPSHOT_DELTAS=1` runs the model on the report's snapshot, for the A/B. @@ -374,6 +490,8 @@ fn real_data_reproduction() { let (mut created_n, mut created_hits, mut stamped) = (0usize, 0usize, 0usize); // MoonShot trades whose saved corridor (`Deal::corridor`) the model's band matches in width. let (mut corridor_n, mut corridor_hits) = (0usize, 0usize); + // The order's path from its creation against the archived entry line. + let mut path = PathTally::default(); let (mut own_sum, mut fact_sum) = (0.0f64, 0.0f64); for mut deal in read.deals { *kinds_seen.entry(deal.kind.clone()).or_default() += 1; @@ -447,17 +565,24 @@ fn real_data_reproduction() { values: &values, defaults: &defaults, }; + // `MOON_TICKS_LATENCY_MS=` replays the entry with another replacement latency. + let latency = std::env::var("MOON_TICKS_LATENCY_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_LATENCY_MS); + let core_latency = match (latency_base, round_trips.get(&deal.core_uid)) { + (Some(base), Some(rt)) => base + rt, + _ => latency, + }; let entry = if entry_model_for(&deal.kind) { - // `MOON_TICKS_LATENCY_MS=` replays the entry with another replacement latency. - let latency = std::env::var("MOON_TICKS_LATENCY_MS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_LATENCY_MS); - EntryParams::MoonShot(mshot_params(&sv, latency)) + EntryParams::MoonShot(mshot_params(&sv, core_latency)) } else { EntryParams::Fact }; - let exit = exit_params(&sv); + let mut exit = exit_params(&sv); + if std::env::var_os("MOON_TICKS_LATENCY_EXIT").is_some() { + exit.latency_ms = core_latency; + } deal.step_lag_ms = core_lags.get(&deal.core_uid).copied().unwrap_or(0.0); if let (Some(cache), Some((exchange, market))) = (klines.as_ref(), address.as_ref()) { let btc = btc_of_exchange.get(exchange).map(String::as_str); @@ -494,6 +619,42 @@ fn real_data_reproduction() { corridor_hits += usize::from((down.max(up) / down.min(up) / predicted - 1.0).abs() <= 0.0005); } + // The order's path from its creation, where the model replays the whole of it. + if let (EntryParams::MoonShot(params), Some(line)) = (&entry, entry_line.as_deref()) { + let from_creation = deal.entry_placed.is_some() + && deal + .order_open_ms() + .is_some_and(|created| (ticks[0].time_ms as i64) <= created); + if from_creation { + let (_, moves) = + super::super::mshot::MshotEntry::new(params).trace(&deal, &ticks, Some(line)); + let moved = params.modifiers.near_addition(&deal.deltas) != 0.0; + path.add(&deal, &moves, line, moved); + if std::env::var_os("MOON_TICKS_PATH_DEBUG").is_some() { + let created = deal.order_open_ms().unwrap_or(deal.buy_ms); + let fmt = |pts: &[(i64, f64)]| -> String { + pts.iter() + .take(14) + .map(|(t, p)| format!("{:+}ms {:.8}", t - created, p)) + .collect::>() + .join(", ") + }; + eprintln!( + " path {} {} buy {:+}ms fast {} rw {} rd {} near {:.3} far {:.3}\n model : {}\n archive: {}", + deal.coin, + deal.core_name, + deal.buy_ms - created, + params.fast_algo, + params.raise_wait_s, + params.replace_delay_s, + params.bounds_pct(&deal.deltas_at(created)).0, + params.bounds_pct(&deal.deltas_at(created)).1, + fmt(&moves), + fmt(&verify::archived_replacements(line)), + ); + } + } + } // The modelled line beside the archive's moves, for the eye. if let (Some(fill), Some(moves)) = ( simulate(&deal, &ticks, &entry, &exit, entry_line.as_deref()).fill, @@ -536,6 +697,7 @@ fn real_data_reproduction() { exit_points.as_deref(), entry_line.as_deref(), &entry, + &exit, ); } eprintln!( @@ -676,6 +838,30 @@ fn real_data_reproduction() { "entry from the order's creation: ✓ {created_hits}/{created_n} · stamped entries {stamped}" ); eprintln!("saved corridors the model's band matches to 0.05 %: {corridor_hits}/{corridor_n}"); + eprintln!( + "entry path from the creation: {} deals, whole path {} · archived moves matched {}/{} · model moves the archive lacks {}", + path.deals, path.whole, path.matched, path.archived, path.extra + ); + eprintln!( + "entry path timing: {}/{} archived moves have a model move within the second", + path.timed, path.archived + ); + for (name, errors) in ["with MShotAdd*", "without"].iter().zip(&path.level_errors) { + let mut abs: Vec = errors.iter().map(|e| e.abs()).collect(); + abs.sort_by(f64::total_cmp); + let q = |f: f64| { + abs.get(((abs.len() as f64 - 1.0) * f) as usize) + .map(|v| (v * 1000.0).round() / 1000.0) + }; + eprintln!( + "entry path level, {name}: {} moves · |err| p25 {:?} p50 {:?} p75 {:?} · model above {}", + errors.len(), + q(0.25), + q(0.5), + q(0.75), + errors.iter().filter(|e| **e > 0.0).count() + ); + } print_delta_quality(&tracks, no_track); let mut unfit: Vec<(String, usize)> = unfit.into_iter().collect(); unfit.sort_by_key(|u| std::cmp::Reverse(u.1)); diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 89841d1c..93a14657 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -54,6 +54,14 @@ use crate::feed::types::Tick; /// move: the archive stamps the core's own moment, the model the print that triggered it. pub const POINT_TIME_TOLERANCE_MS: i64 = 1_000; +/// How far apart a modelled and a factual book-watching stop (`FastStopLoss` off) may fire and +/// still be the same stop: one gap of the REST ticker the core reads that stop's price off — +/// 2–2.3 s (the core developer, 2026-09-23). The core fires on the first arrival that puts the +/// price past the level, the model on its own sample clock, and the ticker's phase is on no +/// record: a dump through the level fires both at their next arrival, up to a whole gap apart. +/// On the 192 live book-watching stops of 2026-09-23, a second judged 112 on time, the gap 141. +pub const BOOK_STOP_TIME_TOLERANCE_MS: i64 = 2_300; + /// Tolerance on a STOP's level: the modelled level against the one the core fixed carries /// `StopLossModifier` over deltas the model only partly re-reads live — the coin's ranges where /// the deal has a track, the BTC, market, mark and price-bug terms as the report's one snapshot @@ -493,8 +501,12 @@ fn verify_stop( (matched_points(modelled, &moves), moves.len()) }); let line_ok = points.is_none_or(|(matched, total)| matched == total); - let on_time = - (closed.t_ms - activation.unwrap_or(deal.close_ms)).abs() <= POINT_TIME_TOLERANCE_MS; + let tolerance_ms = if exit.fast_stop_loss { + POINT_TIME_TOLERANCE_MS + } else { + BOOK_STOP_TIME_TOLERANCE_MS + }; + let on_time = (closed.t_ms - activation.unwrap_or(deal.close_ms)).abs() <= tolerance_ms; match stated { Some(stated) => { let dev = deviation_pct(level, stated); From bcc144a54f3d56188093f95127e7ce2e90884062 Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 17:40:09 +0200 Subject: [PATCH 25/51] feat(tuner): read the long ranges off closed candles and re-seed BTC'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. --- .../src/db/tuner/ticks/deltas/btc.rs | 85 +++++++++++--- .../src/db/tuner/ticks/deltas/coin.rs | 110 +++++++++++++----- .../src/db/tuner/ticks/deltas/mod.rs | 28 ++--- .../src/db/tuner/ticks/deltas/quality.rs | 2 +- .../src/db/tuner/ticks/deltas/tests.rs | 34 +++++- .../src/db/tuner/ticks/tests/real_data.rs | 45 ++++++- 6 files changed, 237 insertions(+), 67 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/btc.rs b/crates/moon-core/src/db/tuner/ticks/deltas/btc.rs index 9cd45e0e..eae063a2 100644 --- a/crates/moon-core/src/db/tuner/ticks/deltas/btc.rs +++ b/crates/moon-core/src/db/tuner/ticks/deltas/btc.rs @@ -4,18 +4,24 @@ //! //! - `btc5mdelta`, `dbtc1m`: BTC's range over the last five minutes and the last minute, //! `(max / min − 1) · 100`; -//! - `btc1hdelta`: signed — how far BTC's price stands from its one-hour average, the average an -//! exponential one stepped every thirty seconds with weight 0.01 (moonproto: `avg = p · 0.01 + -//! avg · 0.99`), `(price − average) / average · 100`. +//! - `btc1hdelta`: signed — how far BTC's price stands from its one-hour average, +//! `(price − average) / average · 100`. The core developer (2026-09-23): the average is +//! re-seeded on EVERY five-minute close as the mean OHLC4 of the last hour's closed candles, +//! and between closes stepped every thirty seconds with weight 0.01 (moonproto: `avg = p · +//! 0.01 + avg · 0.99`) — so it is an hour's mean with at most ten small steps on top, not the +//! long exponential memory the steps alone would give. The closes are taken on the clock's +//! five-minute grid, the phase the core starts on (see `coin::REACH`); a five-minute bar off +//! the grid is a candle of its own. At the report's stamp (620 tracks, 2026-09-23) the re-seeded +//! average brought `btc1hdelta`'s median error from 0.084 to 0.054 pp, 331 → 443 within 0.1. //! //! The history is whatever bars the kline cache holds for that market — the minute bars where //! something fetched them, the recorder's five-minute ones elsewhere. A window narrower than the //! bar it is read off takes that bar whole, so on five-minute bars `dbtc1m` is a bar's range, not //! a minute's: the summary says how much of each window the history covered. -use super::MINUTE_MS; use super::field::DeltaField; use super::series::{Extremes, Series}; +use super::{CANDLE_MS, MINUTE_MS}; /// The step the core moves BTC's average by (moonproto: at most every 30 s). const AVERAGE_STEP_MS: i64 = 30_000; @@ -40,7 +46,10 @@ pub(super) struct BtcEval<'a> { series: &'a Series, windows: [Extremes; 2], next: usize, - average: Option, + /// The first item whose candle is still inside the hour the average is seeded from. + hour_first: usize, + /// The seed and the close it was taken at. + seed: Option<(i64, f64)>, /// The last complete bar's close, its end, and its length. last: Option<(f64, i64, i64)>, } @@ -51,11 +60,51 @@ impl<'a> BtcEval<'a> { series, windows: [Extremes::new(), Extremes::new()], next: 0, - average: None, + hour_first: 0, + seed: None, last: None, } } + /// The mean OHLC4 of the candles that closed in the hour up to `close`, the core's five-minute + /// candles put together from whatever bars end inside each: its open the first bar's, its + /// close the last's. `None` when the hour holds none. + fn hour_mean(&mut self, close: i64) -> Option { + let series: &'a Series = self.series; + let items = &series.items; + let from = close - 60 * MINUTE_MS; + while self.hour_first < items.len() && items[self.hour_first].end_ms <= from { + self.hour_first += 1; + } + let (mut sum, mut count) = (0.0, 0usize); + // The candle being put together: its close on the grid, open, high, low, close. + let mut candle: Option<(i64, f64, f64, f64, f64)> = None; + let mut flush = |c: Option<(i64, f64, f64, f64, f64)>| { + if let Some((_, o, h, l, c)) = c { + sum += (o + h + l + c) / 4.0; + count += 1; + } + }; + for item in items[self.hour_first..] + .iter() + .take_while(|i| i.end_ms <= close) + { + // The grid close of the candle the bar ends in: `end` inside `(c − 5 min, c]`. + let grid = (item.end_ms + CANDLE_MS - 1).div_euclid(CANDLE_MS) * CANDLE_MS; + candle = match candle { + Some((g, o, h, l, _)) if g == grid => { + Some((g, o, h.max(item.high), l.min(item.low), item.close)) + } + other => { + flush(other); + Some((grid, item.open, item.high, item.low, item.close)) + } + }; + } + flush(candle); + (count > 0).then(|| sum / count as f64) + } + /// BTC's fields at a boundary, over the bars complete before it. Called at ascending /// boundaries. pub(super) fn at(&mut self, at: i64) -> BtcPoint { @@ -67,26 +116,28 @@ impl<'a> BtcEval<'a> { window.push(self.next, items); } let length = (item.end_ms - item.from_ms).max(1); - self.average = Some(match self.average { - // Seeded as the core seeds it, off a candle's mean price. - None => (item.open + item.high + item.low + item.close) / 4.0, - Some(average) => { - let steps = (length / AVERAGE_STEP_MS).max(1) as i32; - let keep = AVERAGE_KEEP.powi(steps); - item.close * (1.0 - keep) + average * keep - } - }); self.last = Some((item.close, item.end_ms, length)); self.next += 1; } + // Re-seeded on every five-minute close, then stepped toward the price every thirty + // seconds since it. + let close = at.div_euclid(CANDLE_MS) * CANDLE_MS; + // An hour without a closed candle has no average: the field answers nothing there rather + // than stepping an old seed toward the price for as long as the hole lasts. + if self.seed.is_none_or(|(seeded, _)| seeded != close) { + self.seed = self.hour_mean(close).map(|mean| (close, mean)); + } let fresh = self.last.filter(|&(_, end, _)| end > at - STALE_MS); let bar = fresh.map_or(0, |(_, _, length)| length); let reach_1m = MINUTE_MS.max(bar); let reach_5m = (5 * MINUTE_MS).max(bar); let btc1m = fresh.and_then(|_| self.windows[0].range_at(at, reach_1m, items)); let btc5m = fresh.and_then(|_| self.windows[1].range_at(at, reach_5m, items)); - let btc1h = match (fresh, self.average) { - (Some((price, _, _)), Some(average)) if average > 0.0 => { + let btc1h = match (fresh, self.seed) { + (Some((price, _, _)), Some((seeded, mean))) if mean > 0.0 => { + let steps = ((at - seeded).max(0) / AVERAGE_STEP_MS) as i32; + let keep = AVERAGE_KEEP.powi(steps); + let average = price * (1.0 - keep) + mean * keep; Some((price - average) / average * 100.0) } _ => None, diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/coin.rs b/crates/moon-core/src/db/tuner/ticks/deltas/coin.rs index 9f6035be..f558c1a2 100644 --- a/crates/moon-core/src/db/tuner/ticks/deltas/coin.rs +++ b/crates/moon-core/src/db/tuner/ticks/deltas/coin.rs @@ -5,10 +5,10 @@ //! are defined by the FAQ in words only and checked on 91 Binance trades (2026-09-23): //! //! - Pump1h (FAQ :1047 "the difference between the price an hour ago and the hour's high"): -//! `(high / price an hour ago − 1) · 100` met the report exactly on 3 trades, median error -//! 0.24 pp — the core's "an hour ago" is a moment of its own; -//! - Dump1h ("… and the hour's low"): `(price an hour ago − low) / price an hour ago · 100`, -//! exactly on 5, median 0.15 pp; +//! `(high / open − 1) · 100`, and Dump1h `(1 − low / open) · 100`, where "an hour ago" is the +//! OPEN of the oldest of the core's candles in the hour's window — twelve closed and the open +//! one (the core developer, 2026-09-23); off the price sixty minutes before the moment they had +//! met the report exactly on 3 and 5 of 91 trades; //! - d5s (no definition anywhere; RTTI `Last5sDelta`): the move over the last five-second //! bucket, `|last price / the last price a bucket earlier − 1| · 100`, median 0.07 pp — closer //! than the bucket's range (0.10 pp). @@ -20,19 +20,44 @@ use super::field::DeltaField; use super::series::{Extremes, Series}; use super::{CANDLE_MS, LOOKBACK_MS, MINUTE_MS, STEP_MS}; -/// How far back each window the coin's fields are read over reaches: the short ones are whole -/// five-second buckets, so their reach is their name; a window over the core's candles reaches -/// one candle further (measured on 86 trades, d15m's median error fell from 0.21 pp to 0.02 pp -/// with the extra candle). The last one is the plain hour Pump1h and Dump1h look back over. +/// How far back each window the coin's fields are read over reaches. +/// +/// The short ones are whole five-second buckets, so their reach is their name. +/// +/// The core keeps a candle while its CLOSE is younger than the window (the core developer, +/// 2026-09-23): d15m and d1h take three and twelve closed candles plus the one still open, +/// "d3h" and "d24h" closed candles only, four hours and twenty-five of them, and Pump1h / Dump1h +/// the hour's twelve plus the open one. The core closes its candles on the clock's five-minute +/// grid from its start and drifts off it (it closes one once more than five minutes passed, +/// checked about once a second), so the grid is only the model's guess at the phase. Measured +/// on 1 122 trades at the report's stamp (2026-09-23), the grid brings d3h's median error from +/// 0.034 to 0.022 pp, d24h's from 0.324 to 0.171, Pump1h's 0.313 → 0.302, Dump1h's 0.268 → +/// 0.250, and moves nothing downstream. d15m and d1h on the grid met the stamp closer too +/// (0.150 → 0.135 pp) but moved worse AFTER it — the window losing a candle at a grid close the +/// core had not reached: MoonHook takes, placed seconds past the stamp, went 38 → 35 within +/// 0.05 pp of the core's, the MoonShot corridor's level 0.213 → 0.221 % off the archive — so +/// those two keep a sliding window one candle past their name, counted from the moment. const REACH: [i64; 7] = [ MINUTE_MS, 5 * MINUTE_MS, 15 * MINUTE_MS + CANDLE_MS, 60 * MINUTE_MS + CANDLE_MS, - 4 * 60 * MINUTE_MS + CANDLE_MS, - LOOKBACK_MS, + 4 * 60 * MINUTE_MS, + LOOKBACK_MS - CANDLE_MS, 60 * MINUTE_MS, ]; + +/// Whether a window is counted from the last candle close ([`REACH`]). +const ON_CANDLES: [bool; 7] = [false, false, false, false, true, true, true]; + +/// Whether a window takes only closed candles — the one still open reaches "d3h" and "d24h" +/// only through d1h, which floors them. +const CLOSED_ONLY: [bool; 7] = [false, false, false, false, true, true, false]; + +/// The last candle close at or before a moment, on the clock's five-minute grid. +fn last_close(at: i64) -> i64 { + at.div_euclid(CANDLE_MS) * CANDLE_MS +} const M1: usize = 0; const M5: usize = 1; const M15: usize = 2; @@ -62,9 +87,12 @@ pub(super) const FIELDS: [DeltaField; 9] = [ pub(super) struct CoinEval<'a> { series: &'a Series, windows: [Extremes; 7], - /// The next item to enter the windows. + /// The next item to enter the windows that read up to the moment. next: usize, - /// The earliest item still inside the plain hour — its open is "the price an hour ago". + /// The next item to enter the windows of closed candles ([`CLOSED_ONLY`]). + next_closed: usize, + /// The earliest item still inside the hour's window — its open is the open of the oldest + /// candle there, the price Pump1h and Dump1h count from. hour_first: usize, /// The last price before the previous boundary, and that boundary. prev: Option<(i64, f64)>, @@ -76,6 +104,7 @@ impl<'a> CoinEval<'a> { series, windows: std::array::from_fn(|_| Extremes::new()), next: 0, + next_closed: 0, hour_first: 0, prev: None, } @@ -86,14 +115,33 @@ impl<'a> CoinEval<'a> { pub(super) fn at(&mut self, at: i64) -> CoinPoint { let series: &'a Series = self.series; let items = &series.items; + let closed = last_close(at); while self.next < items.len() && items[self.next].end_ms <= at { - for window in &mut self.windows { - window.push(self.next, items); + for (w, window) in self.windows.iter_mut().enumerate() { + if !CLOSED_ONLY[w] { + window.push(self.next, items); + } } self.next += 1; } - let ranges: [Option; 6] = - std::array::from_fn(|w| self.windows[w].range_at(at, REACH[w], items)); + while self.next_closed < items.len() && items[self.next_closed].end_ms <= closed { + for (w, window) in self.windows.iter_mut().enumerate() { + if CLOSED_ONLY[w] { + window.push(self.next_closed, items); + } + } + self.next_closed += 1; + } + // Each window's `(start, end]`: from the last close or the moment, back by its reach. + let span = |w: usize| { + let from = if ON_CANDLES[w] { closed } else { at } - REACH[w]; + let to = if CLOSED_ONLY[w] { closed } else { at }; + (from, to) + }; + let ranges: [Option; 6] = std::array::from_fn(|w| { + let (from, _) = span(w); + self.windows[w].range_at(at, at - from, items) + }); let long = |wide: Option| match (ranges[H1], wide) { (Some(h1), Some(w)) => Some(h1.max(w)), (h1, w) => h1.or(w), @@ -109,13 +157,14 @@ impl<'a> CoinEval<'a> { if let Some(now) = last { self.prev = Some((at, now)); } - // Pump and dump over the plain hour, off the price it began at. - let hour_start = at - REACH[HOUR]; + // Pump and dump over the hour's candles, off the open of the oldest (the core developer, + // 2026-09-23), not off the price sixty minutes before the moment. + let (hour_start, _) = span(HOUR); while self.hour_first < self.next && items[self.hour_first].end_ms <= hour_start { self.hour_first += 1; } let (pump, dump) = match ( - self.windows[HOUR].extremes_at(at, REACH[HOUR], items), + self.windows[HOUR].extremes_at(at, at - hour_start, items), (self.hour_first < self.next).then(|| items[self.hour_first].open), ) { (Some((high, low)), Some(open)) if open > 0.0 => ( @@ -124,17 +173,20 @@ impl<'a> CoinEval<'a> { ), _ => (None, None), }; - let cover = |reach: i64| series.covered_fraction(at - reach, at); + let cover = |w: usize| { + let (from, to) = span(w); + series.covered_fraction(from, to) + }; [ - (ranges[M1], cover(REACH[M1])), - (ranges[M5], cover(REACH[M5])), - (ranges[M15], cover(REACH[M15])), - (ranges[H1], cover(REACH[H1])), - (long(ranges[H4]), cover(REACH[H4])), - (long(ranges[H25]), cover(REACH[H25])), - (d5s, cover(STEP_MS)), - (pump, cover(REACH[HOUR])), - (dump, cover(REACH[HOUR])), + (ranges[M1], cover(M1)), + (ranges[M5], cover(M5)), + (ranges[M15], cover(M15)), + (ranges[H1], cover(H1)), + (long(ranges[H4]), cover(H4)), + (long(ranges[H25]), cover(H25)), + (d5s, series.covered_fraction(at - STEP_MS, at)), + (pump, cover(HOUR)), + (dump, cover(HOUR)), ] } } diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs b/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs index 1110c0fa..be62f08b 100644 --- a/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs @@ -9,11 +9,11 @@ //! //! - every coin delta is a RANGE, `(max / min − 1) · 100` over a window — never negative; //! - the long ones run over the core's five-minute candles, stamped at their END and kept while -//! younger than the window, so a window reaches up to one candle past its name: d15m, d1h, and -//! "d3h" over candles younger than four hours (the FAQ counts that "3ч55м"; the oldest candle -//! began up to 4h05m ago, and on 52 MoonShot trades the report exceeded the range of 3h55m + one -//! candle five times, of 4h + one candle twice) and "d24h" over twenty-five, both never under -//! d1h; +//! younger than the window, so a window reaches up to one candle past its name: d15m and d1h +//! over the closed candles and the open one; "d3h" and "d24h" over CLOSED candles younger than +//! four hours and twenty-five (the FAQ counts the first "3ч55м"; the core developer, +//! 2026-09-23), both never under d1h — the open candle reaches them only through it (see +//! `coin::REACH` for the phase and what it measured); //! - the short ones, d1m and d5m, run over five-second buckets of trades; //! - the core refreshes them on those five-second buckets. A value holds from one bucket boundary //! to the next and is computed over what printed BEFORE the boundary: the MoonShot report's @@ -68,13 +68,14 @@ const MINUTE_MS: i64 = 60_000; /// How far a window over the core's candles reaches past its name: one candle. const CANDLE_MS: i64 = 5 * MINUTE_MS; -/// How far before a window the widest coin delta reaches: "d24h", twenty-five hours of candles -/// plus one. +/// How far before a window the widest coin delta reaches: "d24h", twenty-five hours of closed +/// candles counted back from the last close, which lies up to one candle before the moment. pub const LOOKBACK_MS: i64 = 25 * 60 * MINUTE_MS + CANDLE_MS; -/// How far before a window BTC's history is read: the hour average forgets a price in about -/// fifty minutes (weight 0.01 every thirty seconds), and four hours of it leave nothing of the -/// seed. +/// How far before a window BTC's history is read: the hour average is re-seeded off the last +/// hour's candles at every close (see `btc`), and the five-minute range looks back five minutes, +/// so an hour and a candle is what any moment needs; four hours leave room for a window that +/// opens on a hole in the history. const BTC_LOOKBACK_MS: i64 = 4 * 60 * MINUTE_MS; /// One bar of history: what printed over `[from_ms, to_ms)`. @@ -116,8 +117,9 @@ impl Segment { pub struct StampCheck { /// The share of each field's window the history covered at the stamp, 0 … 1. pub coverage: [f64; DeltaField::COUNT], - /// `|evaluation − report|` at the stamp, per cent points; `None` where the history had - /// nothing, or the report never filled the field. + /// `evaluation − report` at the stamp, per cent points — positive where the history saw a + /// wider move than the core; `None` where the history had nothing, or the report never + /// filled the field. pub error: [Option; DeltaField::COUNT], } @@ -253,7 +255,7 @@ impl DeltaTrack { if !estimate[i].is_finite() || unfilled { continue; } - self.stamp.error[i] = Some((estimate[i] - report).abs()); + self.stamp.error[i] = Some(estimate[i] - report); self.live[i] = true; offset[i] = report - estimate[i]; } diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/quality.rs b/crates/moon-core/src/db/tuner/ticks/deltas/quality.rs index 5d0cda23..5105c471 100644 --- a/crates/moon-core/src/db/tuner/ticks/deltas/quality.rs +++ b/crates/moon-core/src/db/tuner/ticks/deltas/quality.rs @@ -58,7 +58,7 @@ pub fn summarize<'a>(tracks: impl IntoIterator) -> DeltaQ live[i] += 1; coverage[i].push(stamp.coverage[i]); if let Some(error) = stamp.error[i] { - errors[i].push(error); + errors[i].push(error.abs()); } } } diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs b/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs index 5ff7307b..7e8ca5e9 100644 --- a/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs @@ -96,6 +96,23 @@ fn a_candle_window_reaches_one_candle_past_its_name() { } } +/// "d3h" and "d24h" read closed candles only, younger than their window by the last close: a +/// spike in a bar that ended 4 h 4 min before T0 — whose last close was 200 s before it — is out +/// of d3h, where a window reaching 4 h 5 min back from the moment took it. +#[test] +fn the_long_ranges_read_closed_candles_only() { + let mut bars = day_of_bars(); + for b in &mut bars { + if b.to_ms == T0 - 4 * 60 * MINUTE_MS - 4 * MINUTE_MS { + b.high = 120.0; + } + } + let ticks = [tick(T0 + 1_000, 100.0)]; + let track = raw(&bars, &ticks, (T0, T0 + 60_000), (T0, T0 + 60_000)); + assert!(close(track.value(T0, DeltaField::D3h).unwrap(), 0.0)); + assert!(close(track.value(T0, DeltaField::D24h).unwrap(), 20.0)); +} + #[test] fn a_window_is_read_over_whatever_history_it_has() { // Six hours of bars, with a hole in them: every window answers off what is there, and the @@ -133,10 +150,12 @@ fn a_window_is_read_over_whatever_history_it_has() { ); let d1h = stamp.coverage[DeltaField::D1h.index()]; assert!(d1h > 0.8 && d1h < 0.9, "a ten-minute hole in 65: {d1h}"); - // The six hours' range against the report's twelve: the error before the anchor. - let range = (105.0 / 95.0 - 1.0) * 100.0; + // The six hours' range against the report's twelve: the error before the anchor, negative + // where the history saw the narrower move. The 105 printed in the open candle, which reaches + // d24h only through d1h: the closed candles' 100 / 95 is the wider of the two. + let range = (100.0 / 95.0 - 1.0) * 100.0; let error = stamp.error[DeltaField::D24h.index()].unwrap(); - assert!(close(error, 12.0 - range), "{error}"); + assert!(close(error, range - 12.0), "{error}"); assert!(close(track.apply(T0 + 5_000, &snapshot).d24h, 12.0)); } @@ -192,7 +211,8 @@ fn the_anchor_puts_the_track_on_the_report_at_its_stamp() { "a field the report never filled: {later:?}" ); assert!(!track.is_live(DeltaField::D15m)); - assert_eq!(track.stamp().error[DeltaField::D1m.index()], Some(1.0)); + // Signed: the evaluation saw 0 where the report said 1. + assert_eq!(track.stamp().error[DeltaField::D1m.index()], Some(-1.0)); } #[test] @@ -314,8 +334,10 @@ fn btc_reads_its_own_market() { .unwrap(); assert!(close(track.value(T0, DeltaField::Btc1m).unwrap(), 1.0)); assert!(close(track.value(T0, DeltaField::Btc5m).unwrap(), 1.0)); - // One minute bar is two of the average's steps: it moved 1 − 0.99² of the way up. - let average = 50_000.0 + 500.0 * (1.0 - 0.99f64.powi(2)); + // The average was re-seeded at the last five-minute close, 200 s before T0, off the hour's + // flat candles — 50 000 — and stepped six times since toward the price. + assert_eq!(T0.rem_euclid(CANDLE_MS), 200_000); + let average = 50_000.0 + 500.0 * (1.0 - 0.99f64.powi(6)); let expected = (50_500.0 - average) / average * 100.0; assert!(close(track.value(T0, DeltaField::Btc1h).unwrap(), expected)); // Without BTC's bars the fields keep the snapshot. diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index f95c1ab0..6b08a5a8 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -35,6 +35,9 @@ use crate::market::kline_cache::KlineCache; use crate::market::trade_replay::{Coverage, TickQuery, long_position_ms, query_held}; use crate::symbol::{coin_match_key, coin_of_market}; +/// One trade's d1m and d5m errors at the report's stamp (`StampCheck::error`). +type ShortErrors = (Option, Option); + /// Every point of an archived entry line and of an exit line, and whether the core answered /// for the deal with lines at all. type ArchivedLines = (Option>, Option>, bool); @@ -476,6 +479,9 @@ fn real_data_reproduction() { let btc_of_exchange = btc_markets(); eprintln!("BTC markets: {btc_of_exchange:?}"); let mut tracks: Vec> = Vec::new(); + // Each core's d1m and d5m errors at the stamp — a core whose `DeltasByTrades` is off reads + // one point per tick, not every print, and shows as its own error level. + let mut per_core_short: HashMap> = HashMap::new(); let mut no_track = 0usize; eprintln!("PriceDown step lag per core: {core_lags:?}"); let (mut entry_hits, mut entry_n, mut exit_hits, mut exit_n, mut with_tape) = (0, 0, 0, 0, 0); @@ -588,7 +594,17 @@ fn real_data_reproduction() { let btc = btc_of_exchange.get(exchange).map(String::as_str); let track = deltas::track_for(cache, exchange, market, btc, &deal, &ticks, &covered); match &track { - Some(track) => tracks.push(track.clone()), + Some(track) => { + tracks.push(track.clone()); + let error = |field: deltas::DeltaField| track.stamp().error[field.index()]; + per_core_short + .entry(deal.core_name.clone()) + .or_default() + .push(( + error(deltas::DeltaField::D1m), + error(deltas::DeltaField::D5m), + )); + } None => no_track += 1, } if !snapshot_only { @@ -863,6 +879,33 @@ fn real_data_reproduction() { ); } print_delta_quality(&tracks, no_track); + let mut cores: Vec<_> = per_core_short.into_iter().collect(); + cores.sort_by_key(|(_, v)| std::cmp::Reverse(v.len())); + for (core, errors) in cores { + // |error| median, how many within 0.1 pp, how many where the history saw the wider move. + let median = |pick: fn(&ShortErrors) -> Option| { + let signed: Vec = errors.iter().filter_map(pick).collect(); + let mut v: Vec = signed.iter().map(|e| e.abs()).collect(); + v.sort_by(f64::total_cmp); + let within = v.iter().filter(|e| **e <= 0.1).count(); + let wider = signed.iter().filter(|e| **e > 0.1).count(); + let narrower = signed.iter().filter(|e| **e < -0.1).count(); + ( + v.get(v.len() / 2).copied(), + within, + v.len(), + wider, + narrower, + ) + }; + let (d1m, d1m_in, d1m_n, d1m_w, d1m_nr) = median(|e| e.0); + let (d5m, d5m_in, d5m_n, d5m_w, d5m_nr) = median(|e| e.1); + eprintln!( + " core {core:12} d1m median {:?} within 0.1 {d1m_in}/{d1m_n} wider {d1m_w} narrower {d1m_nr} · d5m median {:?} within 0.1 {d5m_in}/{d5m_n} wider {d5m_w} narrower {d5m_nr}", + d1m.map(|v| (v * 1000.0).round() / 1000.0), + d5m.map(|v| (v * 1000.0).round() / 1000.0), + ); + } let mut unfit: Vec<(String, usize)> = unfit.into_iter().collect(); unfit.sort_by_key(|u| std::cmp::Reverse(u.1)); eprintln!("fit for the search: {fit_n} of {with_tape} · left out: {unfit:?}"); From 59671c0c68578919dd0d8623467dbaaca8b25534 Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 18:24:09 +0200 Subject: [PATCH 26/51] feat(tuner): replay a MoonShot variant's entry by the corridor model 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). --- crates/moon-core/src/db/tuner/ticks/entry.rs | 13 +- crates/moon-core/src/db/tuner/ticks/mod.rs | 17 +- crates/moon-core/src/db/tuner/ticks/mshot.rs | 143 +++++++- crates/moon-core/src/db/tuner/ticks/params.rs | 5 +- crates/moon-core/src/db/tuner/ticks/search.rs | 19 +- .../src/db/tuner/ticks/search/tests.rs | 4 + crates/moon-core/src/db/tuner/ticks/tests.rs | 76 +++++ .../src/db/tuner/ticks/tests/real_data.rs | 311 ++++++++++++++++++ .../src/analytics/tuner/ticks/state.rs | 6 +- .../src/analytics/tuner/ticks/variants.rs | 4 + 10 files changed, 579 insertions(+), 19 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/entry.rs b/crates/moon-core/src/db/tuner/ticks/entry.rs index 993eabdc..12698b47 100644 --- a/crates/moon-core/src/db/tuner/ticks/entry.rs +++ b/crates/moon-core/src/db/tuner/ticks/entry.rs @@ -9,8 +9,8 @@ //! `Hook*` fields) is the first candidate for a second implementation; it adds one arm to //! [`entry_model_for`] and nothing to the UI. -use super::mshot::MshotEntry; -use super::{Deal, Fill}; +use super::mshot::{EntryMethod, MshotEntry}; +use super::{Deal, EntryParams, Fill}; use crate::feed::types::Tick; /// The kind name of MoonShot as the strategy list spells it (`feed::strategies` ordinal 6). @@ -37,7 +37,14 @@ pub fn entry_model_for(kind: &str) -> bool { } impl EntryModel for MshotEntry<'_> { + /// By the parameters' [`EntryMethod`]: the corridor model, or the fact shifted — which needs + /// the fact's own parameters ([`Deal::own_entry`]), and without them falls back to the model. fn fill(&self, deal: &Deal, ticks: &[Tick], line: Option<&[(i64, f64)]>) -> Option { - self.run(deal, ticks, line) + match (self.method(), deal.own_entry.as_ref()) { + (EntryMethod::Shift, Some(EntryParams::MoonShot(own))) => { + self.shifted_fill(deal, ticks, own, line) + } + _ => self.run(deal, ticks, line), + } } } diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index f11ef4a3..00f5cbb3 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -44,7 +44,7 @@ pub use deals::{DealsRead, read_deals}; pub use entry::{EntryModel, entry_model_for}; pub use exit::{ExitModel, ExitParams, archived_pre_spike_ask, archived_take, take_model_for}; pub use hook::{HookDetect, KIND_MOONHOOK, hook_take_pct, parse_hook_detect}; -pub use mshot::{MshotEntry, MshotParams, UsePrice}; +pub use mshot::{EntryMethod, MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; pub use record::{OwnLines, StopAnchor, entry_placement, fit_for_search, prepare_deal}; pub use scope::{is_service_row, is_tunable}; @@ -485,9 +485,18 @@ pub fn simulate( }; let fill = match entry { EntryParams::Fact => Some(fact_fill), - // The trade's own entry settings filled where the report says; the model is for the - // entries the core never ran. - EntryParams::MoonShot(_) if deal.own_entry.as_ref() == Some(entry) => Some(fact_fill), + // The trade's own entry settings filled where the report says, whichever way and with + // whatever latency a variant would be replayed — both are the model's, not the + // strategy's; the models are for the entries the core never ran. (The verdict replays + // the own settings through the model on purpose, and does it with `own_entry` cleared.) + EntryParams::MoonShot(params) + if matches!( + deal.own_entry.as_ref(), + Some(EntryParams::MoonShot(own)) if own.same_strategy(params) + ) => + { + Some(fact_fill) + } EntryParams::MoonShot(params) => MshotEntry::new(params).fill(deal, ticks, entry_line), }; let Some(fill) = fill else { diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index 0fe5d83f..d197af96 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -66,6 +66,10 @@ //! window and no archived move taken as given. A VARIANT is placed off the same reference by its //! own bounds ([`MshotEntry::placement_at_creation`]) instead of inheriting the fact's level, //! which is what a search over `MShotPrice` asks about. +//! +//! All of the above is the corridor MODEL. A variant can instead be replayed as a SHIFT of the +//! fact ([`EntryMethod`]): the fact's order where it stood at the spike, moved by the variant's +//! far bound, with no path of its own. use super::verify::archived_replacements; use super::{Deal, Deltas, EntryParams, Fill, deltas, reaches, snap_to_step}; @@ -95,6 +99,27 @@ impl UsePrice { } } +/// How a MoonShot variant's entry is replayed — a setting of the search, not a strategy field. +/// +/// Measured on 2026-09-23 against the one real counterfactual the history holds — 273 pairs of +/// cores with different `MShotPrice` filled on the same spike — predicting the second core's +/// fill from the first's tape and record: the model placed it a median 0.156 % off (99 within +/// 0.1 %) and filled 264; the shift 0.078 % off (141 within 0.1 %) and filled 250. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum EntryMethod { + /// The corridor model from the order's creation ([`MshotEntry::fill`] via `run`): the whole + /// path the variant's order would have walked. The one that answers for `MShotRaiseWait`, + /// `MShotReplaceDelay`, `MShotUsePrice` and `FastShotAlgo`, which change the path. + #[default] + Model, + /// The fact's order at the spike, shifted by the variant's far bound + /// ([`MshotEntry::shifted_fill`]): the path before the spike is the fact's, so only the + /// depth parameters — `MShotPrice`, `MShotPriceMin` through the modifiers' floor, + /// `MShotMinusSatoshi`, `MShotAdd*`, `MShotAddDistance` — move the entry; the waits and the + /// reference do not. + Shift, +} + /// How a family of modifiers reads the market-wide deltas. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MarketSign { @@ -181,6 +206,10 @@ const BOUND_FLOOR_PCT: f64 = 0.02; /// Default replacement latency, milliseconds — see the module doc. pub const DEFAULT_LATENCY_MS: f64 = 100.0; +/// How far past the fact's fill a shifted order ([`MshotEntry::shifted_fill`]) may still be +/// reached by the same spike. +pub const SHIFT_WINDOW_MS: i64 = 2_000; + /// The seconds the FAQ's "4-second-old ASK" of `MShotSellAtLastPrice` looks back. pub const PRE_SPIKE_LOOKBACK_MS: i64 = 4_000; @@ -212,6 +241,21 @@ pub struct MshotParams { pub modifiers: Modifiers, /// Model parameter, not a strategy field: how long a replacement takes to reach the book. pub latency_ms: f64, + /// Model parameter, not a strategy field: how a variant's entry is replayed. + pub method: EntryMethod, +} + +impl MshotParams { + /// Whether two parameter sets are the same strategy — every strategy field equal, whatever + /// the model parameters (`latency_ms`, `method`) say. + pub fn same_strategy(&self, other: &Self) -> bool { + *self + == Self { + latency_ms: self.latency_ms, + method: self.method, + ..other.clone() + } + } } impl Default for MshotParams { @@ -228,6 +272,7 @@ impl Default for MshotParams { fast_algo: false, modifiers: Modifiers::default(), latency_ms: DEFAULT_LATENCY_MS, + method: EntryMethod::Model, } } } @@ -260,6 +305,11 @@ impl<'a> MshotEntry<'a> { Self { params } } + /// How these parameters want a variant's entry replayed. + pub fn method(&self) -> EntryMethod { + self.params.method + } + /// Where the order would stand for a reference price: the far bound away, kept two steps /// off the reference when `MShotMinusSatoshi` says so, snapped to the price grid AWAY from /// the reference (a limit is placed on the grid, and the core rounds toward safety). @@ -313,18 +363,93 @@ impl<'a> MshotEntry<'a> { if fact_far_pct == far_pct { return Some(fact_level); } - // The fact's level is its placement snapped AWAY from the reference (`place`): the level - // before the snap lay within one step of it toward the price, half a step on average, and - // the reference is read back from there — off the snapped level itself it would sit a - // half step too far and every variant with it. (`MShotMinusSatoshi` binds only on a - // corridor narrower than two steps and is not undone.) + Self::reference_of(fact_level, fact_far_pct, deal).map(|r| self.place(r, far_pct, deal)) + } + + /// The reference a placed level stood off, read back from the level and the far bound it was + /// placed with. + /// + /// The level is the placement snapped AWAY from the reference (`place`): before the snap it + /// lay within one step of it toward the price, half a step on average, and the reference is + /// read back from there — off the snapped level itself it would sit a half step too far and + /// every variant with it. (`MShotMinusSatoshi` binds only on a corridor narrower than two + /// steps and is not undone.) + fn reference_of(level: f64, far_pct: f64, deal: &Deal) -> Option { let half_step = deal.tick.filter(|t| *t > 0.0).map_or(0.0, |t| t / 2.0); let reference = if deal.is_long() { - (fact_level + half_step) / (1.0 - fact_far_pct / 100.0) + (level + half_step) / (1.0 - far_pct / 100.0) } else { - (fact_level - half_step) / (1.0 + fact_far_pct / 100.0) + (level - half_step) / (1.0 + far_pct / 100.0) }; - (reference.is_finite() && reference > 0.0).then(|| self.place(reference, far_pct, deal)) + (reference.is_finite() && reference > 0.0).then_some(reference) + } + + /// Where the fact's order stood when the spike came: the archive's last move of the entry line + /// at or before the buy, and its moment; else the buy price, standing since the order's + /// creation (the core files a line only when the order moved) or, without a stamp, since the + /// run-up before the buy. + pub(super) fn fact_anchor(deal: &Deal, line: Option<&[(i64, f64)]>) -> (i64, f64) { + line.map(archived_replacements) + .and_then(|moves| { + moves + .into_iter() + .filter(|&(t, p)| t <= deal.buy_ms && p > 0.0) + .max_by_key(|&(t, _)| t) + }) + .unwrap_or_else(|| { + let since = deal + .order_open_ms() + .unwrap_or(deal.buy_ms - super::RUN_UP_MS); + (since, deal.buy_price) + }) + } + + /// The level these parameters would have held where the fact's order stood at `level`: the + /// same reference, this variant's far bound — both far bounds read off the deltas as they + /// stood when the fact's order was placed there, the moment its far bound was computed (the + /// report's snapshot where the deal has no live track). The fact's own far bound gives the + /// level back. + /// + /// Args: + /// deal: The trade. + /// own: The trade's own entry parameters. + /// (placed_ms, level): When and where the fact's order was placed ([`Self::fact_anchor`]). + fn anchored_level( + &self, + deal: &Deal, + own: &MshotParams, + (placed_ms, level): (i64, f64), + ) -> Option { + let deltas = deal.deltas_at(placed_ms); + let (_, fact_far) = own.bounds_pct(&deltas); + let (_, far) = self.params.bounds_pct(&deltas); + if fact_far == far { + return Some(level); + } + Self::reference_of(level, fact_far, deal).map(|r| self.place(r, far, deal)) + } + + /// The entry as a SHIFT of the fact: the variant's order at [`Self::anchored_level`] from the + /// moment the fact's order last moved — standing where the fact's stood, one far bound deeper + /// or shallower, since the same moment — filled by the first print that reaches it by + /// [`SHIFT_WINDOW_MS`] past the buy: the same spike. Nothing about the corridor is modelled: + /// the order is taken to stand still through the spike, as the fact's did. + pub(super) fn shifted_fill( + &self, + deal: &Deal, + ticks: &[Tick], + own: &MshotParams, + line: Option<&[(i64, f64)]>, + ) -> Option { + let (since, fact_level) = Self::fact_anchor(deal, line); + let level = self.anchored_level(deal, own, (since, fact_level))?; + ticks + .iter() + .map(|t| (t.time_ms as i64, f64::from(t.price))) + .filter(|&(t, p)| t >= since && p > 0.0) + .take_while(|&(t, _)| t <= deal.buy_ms + SHIFT_WINDOW_MS) + .find(|&(_, p)| reaches(p, level, deal.is_long())) + .map(|(t_ms, _)| Fill { t_ms, price: level }) } /// Distance from the reference to the level, per cent, positive when the level is on the @@ -373,6 +498,8 @@ impl<'a> MshotEntry<'a> { (fill, moves) } + /// Args (beyond [`Self::run`]'s): + /// moves: Where to record every move, when asked. fn run_traced( &self, deal: &Deal, diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 34ecf2b6..11e87e7a 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -470,7 +470,9 @@ fn parse_num(s: &str) -> Option { .filter(|v| v.is_finite()) } -/// MoonShot entry parameters out of a strategy's values; `latency_ms` is the model's own. +/// MoonShot entry parameters out of a strategy's values; `latency_ms` is the model's own, and +/// the entry is replayed by the corridor model ([`super::mshot::EntryMethod::Model`]) unless the +/// caller sets another. pub fn mshot_params(v: &StrategyValues<'_>, latency_ms: f64) -> MshotParams { let base = MshotParams::default(); MshotParams { @@ -504,6 +506,7 @@ pub fn mshot_params(v: &StrategyValues<'_>, latency_ms: f64) -> MshotParams { distance_pct: v.num("MShotAddDistance", 0.0), }, latency_ms, + method: base.method, } } diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index aaf2d439..a8641ece 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -14,6 +14,10 @@ //! trade and drops out of `n`; the caller prints "by N of M" beside the column so a variant //! that wins by trading less is visible as such. //! +//! A MoonShot variant's entry is replayed the way the caller picks ([`EntryMethod`]): the corridor +//! model from the order's creation, or the fact's order shifted at the spike. The trade's own +//! settings take the fact's fill either way. +//! //! Chronological order is kept on purpose: the train/holdout cut and the drawdown read the //! SEQUENCE, and the deals arrive sorted by close from `read_deals`. @@ -22,6 +26,7 @@ use std::sync::Arc; use rayon::prelude::*; +use super::mshot::{EntryMethod, MshotParams}; use super::params::{ ParamGroup, ParamKind, StrategyValues, TICK_PARAMS, exit_params, mshot_params, }; @@ -109,6 +114,8 @@ pub struct SearchParams<'a> { pub train_frac: f64, /// Replacement latency of the model, milliseconds. pub latency_ms: f64, + /// How a MoonShot variant's entry is replayed. + pub entry_method: EntryMethod, } /// What the search found. @@ -134,6 +141,7 @@ fn params_of( point: &Point, kind: &str, latency_ms: f64, + entry_method: EntryMethod, ) -> (EntryParams, ExitParams) { let mut values = base.clone(); for (key, value) in point { @@ -144,7 +152,10 @@ fn params_of( defaults, }; let entry = if entry_model_for(kind) { - EntryParams::MoonShot(mshot_params(&sv, latency_ms)) + EntryParams::MoonShot(MshotParams { + method: entry_method, + ..mshot_params(&sv, latency_ms) + }) } else { EntryParams::Fact }; @@ -284,6 +295,7 @@ pub fn suggest( point, params.kind, params.latency_ms, + params.entry_method, ); tally(train, &entry, &exit) }; @@ -374,6 +386,7 @@ pub fn suggest( &point, params.kind, params.latency_ms, + params.entry_method, ); tally(&deals[train_n..], &entry, &exit) }); @@ -395,6 +408,7 @@ pub fn suggest( /// kind: The strategy kind. /// values: The variant's changes over the base, in strategy spelling. /// latency_ms: Replacement latency of the model. +/// entry_method: How a MoonShot variant's entry is replayed. pub fn variant_tally( deals: &[PreparedDeal], base: &HashMap, @@ -402,6 +416,7 @@ pub fn variant_tally( kind: &str, values: &[(String, String)], latency_ms: f64, + entry_method: EntryMethod, ) -> (Tally, f64) { let mut point = Point::new(); for (key, value) in values { @@ -409,7 +424,7 @@ pub fn variant_tally( point.insert(field.key, value.clone()); } } - let (entry, exit) = params_of(base, defaults, &point, kind, latency_ms); + let (entry, exit) = params_of(base, defaults, &point, kind, latency_ms, entry_method); install(|| tally_and_spent(deals, &entry, &exit)) } diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 6b0d1962..eccebb31 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -104,6 +104,7 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { seed: Some(7), train_frac: 1.0, latency_ms: 0.0, + entry_method: EntryMethod::Model, }; let handle = SearchHandle::new(); let result = suggest(&deals, ¶ms, &handle).expect("a result"); @@ -128,6 +129,7 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { "PumpsDetection", &result.values, 0.0, + EntryMethod::Model, ); assert!((tally.profit - 80.0).abs() < 1e-6); assert!((spent - 8_000.0).abs() < 1e-6); @@ -157,6 +159,7 @@ fn the_holdout_is_scored_but_never_fitted_on() { seed: Some(1), train_frac: 0.75, latency_ms: 0.0, + entry_method: EntryMethod::Model, }; let handle = SearchHandle::new(); let result = suggest(&deals, ¶ms, &handle).expect("a result"); @@ -184,6 +187,7 @@ fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { seed: Some(1), train_frac: 1.0, latency_ms: 0.0, + entry_method: EntryMethod::Model, }; let handle = SearchHandle::new(); assert!( diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index d4437aea..e3a563cd 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -592,6 +592,82 @@ fn an_ask_reference_follows_buy_side_prints_only() { assert!((fill.price - 99.99).abs() < 1e-9, "{}", fill.price); } +/// The fact's own parameters, as a prepared deal carries them, and a variant of them replayed by +/// the shift. +fn shifted_variant(price_pct: f64) -> (Deal, MshotParams) { + let deal = Deal { + own_entry: Some(EntryParams::MoonShot(mshot())), + ..deal() + }; + let variant = MshotParams { + price_pct, + method: EntryMethod::Shift, + ..mshot() + }; + (deal, variant) +} + +/// The shift keeps the fact's order where it stood at the spike — 99, one per cent under 100 — +/// and moves it by the variant's far bound: 1.5 % puts it at 98.5, which the spike's 98.4 fills +/// and a spike to 98.6 does not; 0.5 % puts it at 99.5, filled on the way down by the fact's own +/// fill print. A print past the spike's window fills nothing. +#[test] +fn the_shift_moves_the_facts_order_by_the_variants_far_bound() { + let (d, deeper) = shifted_variant(1.5); + let spike = tape(&[(9_000, 100.0), (10_000, 99.0), (10_200, 98.4)]); + let fill = fill_of(&d, &spike, &deeper).expect("the spike reached 98.5"); + assert_eq!(fill.t_ms, 10_200); + assert!((fill.price - 98.5).abs() < 1e-9, "{fill:?}"); + let shallow_spike = tape(&[(9_000, 100.0), (10_000, 99.0), (10_200, 98.6)]); + assert_eq!(fill_of(&d, &shallow_spike, &deeper), None); + let late = tape(&[(9_000, 100.0), (10_000, 99.0), (12_500, 98.0)]); + assert_eq!(fill_of(&d, &late, &deeper), None, "past the spike's window"); + let (d, shallower) = shifted_variant(0.5); + let fill = fill_of(&d, &spike, &shallower).expect("reached on the way down"); + assert_eq!(fill.t_ms, 10_000); + assert!((fill.price - 99.5).abs() < 1e-9, "{fill:?}"); +} + +/// The short mirror: 101 is one per cent over 100, and 1.5 % puts the order at 101.5. +#[test] +fn a_short_shift_mirrors() { + let d = Deal { + own_entry: Some(EntryParams::MoonShot(mshot())), + ..short_deal() + }; + let (_, deeper) = shifted_variant(1.5); + let spike = tape(&[(9_000, 100.0), (10_000, 101.0), (10_300, 101.6)]); + let fill = fill_of(&d, &spike, &deeper).expect("the spike reached 101.5"); + assert!((fill.price - 101.5).abs() < 1e-9, "{fill:?}"); +} + +/// The trade's own settings take the fact's fill whichever way a variant is replayed; without +/// the fact's parameters to shift from, the shift falls back to the model. +#[test] +fn the_shift_keeps_the_fact_and_needs_it() { + let (d, _) = shifted_variant(1.0); + let own_by_shift = EntryParams::MoonShot(MshotParams { + method: EntryMethod::Shift, + ..mshot() + }); + let spike = tape(&[(9_000, 100.0), (10_000, 99.0), (10_200, 98.4)]); + let out = simulate(&d, &spike, &own_by_shift, &ExitParams::default(), None); + assert_eq!(out.fill.map(|f| (f.t_ms, f.price)), Some((10_000, 99.0))); + let (_, deeper) = shifted_variant(1.5); + let bare = deal(); + assert_eq!( + fill_of(&bare, &spike, &deeper), + fill_of( + &bare, + &spike, + &MshotParams { + method: EntryMethod::Model, + ..deeper.clone() + } + ), + ); +} + /// The corridor is measured from the current print, whatever `FastShotAlgo` is; a re-placed /// order goes off the lowest print of the last 100 ms, not off the print that decided the move /// (the core developer, 2026-09-23). diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 6b08a5a8..eb7bccb2 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -321,6 +321,133 @@ impl PathTally { } } +/// The shifts of `MShotPrice` the two entry methods are compared on, per cent points. +const PRICE_SHIFTS: [f64; 5] = [-0.3, -0.15, 0.0, 0.15, 0.3]; + +/// The two ways to replay a MoonShot variant ([`EntryMethod`]), side by side for one shift of +/// `MShotPrice`. +#[derive(Default)] +struct MethodTally { + deals: usize, + /// Fills per method: model, shift. + filled: [usize; 2], + /// Deals the two agree on — both unfilled, or both filled within 0.05 %. + agree: usize, + /// Fill price against the fact's buy, per cent, summed over the filled, per method. + dev_sum: [f64; 2], + /// Fills landing on the fact's own buy (within 0.05 %), per method — the shift of 0 checks it. + on_fact: [usize; 2], +} + +impl MethodTally { + fn add(&mut self, deal: &Deal, fills: [Option; 2]) { + let same = |a: Option, b: Option| match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => (a.price - b.price).abs() <= a.price.abs() * 5e-4, + _ => false, + }; + self.deals += 1; + self.agree += usize::from(same(fills[0], fills[1])); + for (i, fill) in fills.iter().enumerate() { + if let Some(fill) = fill { + self.filled[i] += 1; + let dev = (fill.price - deal.buy_price) / deal.buy_price * 100.0; + self.dev_sum[i] += if deal.is_long() { dev } else { -dev }; + self.on_fact[i] += usize::from(dev.abs() <= 0.05); + } + } + } +} + +/// One MoonShot fill as the cross-core check reads it. +struct CrossRow { + venue: String, + coin: String, + short: bool, + core: u64, + buy_ms: i64, + /// Where the order stood at the spike (`MshotEntry::fact_anchor`). + level: f64, + /// The far bound it stood at, per cent. + far: f64, + /// The spike's extreme on the order's side from the fact's last move to 2 s past the buy. + extreme: f64, + /// The corridor model's own fill of this trade under its own parameters, off the buy, per + /// cent — what the model gets wrong on the same spike; `None` when it never filled. + model_err: Option, +} + +impl CrossRow { + fn reference(&self) -> f64 { + if self.short { + self.level / (1.0 + self.far / 100.0) + } else { + self.level / (1.0 - self.far / 100.0) + } + } +} + +/// Another core's MoonShot fill, to be predicted from this trade's tape: its entry parameters +/// with its corridor as it stood at its own fill (the modifiers folded in), and where it filled. +struct Partner { + core: u64, + venue: String, + coin: String, + short: bool, + buy_ms: i64, + buy_price: f64, + params: MshotParams, +} + +/// The two methods' predictions of partners' real fills. +#[derive(Default)] +struct PartnerTally { + pairs: usize, + /// Fills per method: model, shift. + filled: [usize; 2], + /// |fill − partner's buy| per cent, per method. + errors: [Vec; 2], +} + +/// Every MoonShot trade's partner record, where its core's venue and its strategy are known. +fn partners_of( + deals: &[Deal], + venues: &HashMap, + keys: &[String], + defaults: &HashMap, +) -> Vec { + deals + .iter() + .filter(|d| entry_model_for(&d.kind)) + .filter_map(|d| { + let venue = venues.get(&d.core_uid)?.clone(); + let values = strategy_values_at(d.strategy_id, Some(d.core_uid), d.buy_ms, keys)?; + let own = mshot_params( + &StrategyValues { + values: &values, + defaults, + }, + DEFAULT_LATENCY_MS, + ); + let (near, far) = own.bounds_pct(&d.deltas); + Some(Partner { + core: d.core_uid, + venue, + coin: coin_match_key(&d.coin), + short: d.is_short, + buy_ms: d.buy_ms, + buy_price: d.buy_price, + params: MshotParams { + price_pct: far, + price_min_pct: near, + modifiers: Default::default(), + ..own + }, + }) + }) + .collect() +} + /// Each core's replace round trip off its archived Entry lines /// (`calibrate::replace_round_trip_samples`, the median per core). fn core_round_trips(deals: &[Deal]) -> HashMap { @@ -498,6 +625,14 @@ fn real_data_reproduction() { let (mut corridor_n, mut corridor_hits) = (0usize, 0usize); // The order's path from its creation against the archived entry line. let mut path = PathTally::default(); + // The three entry methods per shift of `MShotPrice`, and the cross-core rows. + let mut methods: Vec = PRICE_SHIFTS + .iter() + .map(|_| MethodTally::default()) + .collect(); + let mut cross: Vec = Vec::new(); + let partners = partners_of(&read.deals, &venue_of_core, &keys, &defaults); + let mut partner_tally = PartnerTally::default(); let (mut own_sum, mut fact_sum) = (0.0f64, 0.0f64); for mut deal in read.deals { *kinds_seen.entry(deal.kind.clone()).or_default() += 1; @@ -635,6 +770,114 @@ fn real_data_reproduction() { corridor_hits += usize::from((down.max(up) / down.min(up) / predicted - 1.0).abs() <= 0.0005); } + // The two ways to replay a variant, on shifts of the trade's own `MShotPrice` — through + // the entry model as the search calls it. At no shift the model is the fact itself + // (`simulate`); the shift is replayed anyway, as the check of its anchor. + if let EntryParams::MoonShot(own) = &entry { + for (shift, tally) in PRICE_SHIFTS.iter().zip(methods.iter_mut()) { + let variant = |method| MshotParams { + price_pct: (own.price_pct + shift).max(own.price_min_pct), + method, + ..own.clone() + }; + let (model, shifted_params) = + (variant(EntryMethod::Model), variant(EntryMethod::Shift)); + let full = if *shift == 0.0 { + Some(Fill { + t_ms: deal.buy_ms, + price: deal.buy_price, + }) + } else { + MshotEntry::new(&model).fill(&deal, &ticks, entry_line.as_deref()) + }; + let shifted = + MshotEntry::new(&shifted_params).fill(&deal, &ticks, entry_line.as_deref()); + if *shift == 0.0 && std::env::var_os("MOON_TICKS_METHOD_DEBUG").is_some() { + let on_fact = |f: Option| { + f.is_some_and(|f| (f.price - deal.buy_price).abs() <= deal.buy_price * 5e-4) + }; + if !on_fact(shifted) { + let (since, level) = MshotEntry::fact_anchor(&deal, entry_line.as_deref()); + let near: Vec = ticks + .iter() + .filter(|t| { + let tt = t.time_ms as i64; + (deal.buy_ms - 300..=deal.buy_ms + 300).contains(&tt) + }) + .take(8) + .map(|t| format!("{:+}:{}", t.time_ms as i64 - deal.buy_ms, t.price)) + .collect(); + eprintln!( + " method miss {} {} buy {} anchor {:+}ms {} shifted {:?} prints {:?}", + deal.coin, + deal.core_name, + deal.buy_price, + since - deal.buy_ms, + level, + shifted.map(|f| (f.t_ms - deal.buy_ms, f.price)), + near, + ); + } + } + tally.add(&deal, [full, shifted]); + } + // Other cores' fills on the same spike, predicted from this trade's tape both ways. + if let Some((venue, _)) = address.as_ref() { + for p in partners.iter().filter(|p| { + p.core != deal.core_uid + && &p.venue == venue + && p.coin == coin_key + && p.short == deal.is_short + && (p.buy_ms - deal.buy_ms).abs() <= 3_000 + }) { + let shift = MshotParams { + method: EntryMethod::Shift, + ..p.params.clone() + }; + let predictions = [ + MshotEntry::new(&p.params).fill(&deal, &ticks, entry_line.as_deref()), + MshotEntry::new(&shift).fill(&deal, &ticks, entry_line.as_deref()), + ]; + partner_tally.pairs += 1; + for (i, fill) in predictions.iter().enumerate() { + if let Some(fill) = fill { + partner_tally.filled[i] += 1; + partner_tally.errors[i] + .push(((fill.price - p.buy_price) / p.buy_price * 100.0).abs()); + } + } + } + } + let (since, level) = MshotEntry::fact_anchor(&deal, entry_line.as_deref()); + let (_, far) = own.bounds_pct(&deal.deltas_at(deal.buy_ms)); + let side = ticks + .iter() + .filter(|t| { + let tt = t.time_ms as i64; + tt >= since && tt <= deal.buy_ms + super::super::mshot::SHIFT_WINDOW_MS + }) + .map(|t| f64::from(t.price)); + let extreme = if deal.is_long() { + side.reduce(f64::min) + } else { + side.reduce(f64::max) + }; + if let (Some(extreme), Some((venue, _))) = (extreme, address.as_ref()) { + cross.push(CrossRow { + venue: venue.clone(), + coin: coin_key.clone(), + short: deal.is_short, + core: deal.core_uid, + buy_ms: deal.buy_ms, + level, + far, + extreme, + model_err: MshotEntry::new(own) + .fill(&deal, &ticks, entry_line.as_deref()) + .map(|f| ((f.price - deal.buy_price) / deal.buy_price * 100.0).abs()), + }); + } + } // The order's path from its creation, where the model replays the whole of it. if let (EntryParams::MoonShot(params), Some(line)) = (&entry, entry_line.as_deref()) { let from_creation = deal.entry_placed.is_some() @@ -878,6 +1121,74 @@ fn real_data_reproduction() { errors.iter().filter(|e| **e > 0.0).count() ); } + for (shift, t) in PRICE_SHIFTS.iter().zip(&methods) { + let mean = |i: usize| t.dev_sum[i] / t.filled[i].max(1) as f64; + eprintln!( + "entry methods, MShotPrice {shift:+.2} pp: {} deals · filled model {} shift {} · agree {} · mean fill vs fact (+ deeper) model {:.3} shift {:.3} % · on the fact's buy model {} shift {}", + t.deals, + t.filled[0], + t.filled[1], + t.agree, + -mean(0), + -mean(1), + t.on_fact[0], + t.on_fact[1], + ); + } + // Different cores on the same spike: one core's level at the spike, shifted by the other's + // far bound, against where the other's order really stood — and whether the first core's + // spike reached the predicted level (the other's order did fill). + let (mut pairs, mut reached, mut ref_err, mut level_err) = (0usize, 0usize, vec![], vec![]); + let (mut model_err, mut model_unfilled) = (vec![], 0usize); + for a in &cross { + for b in cross.iter().filter(|b| { + b.core != a.core + && b.venue == a.venue + && b.coin == a.coin + && b.short == a.short + && (b.buy_ms - a.buy_ms).abs() <= 3_000 + && (b.far - a.far).abs() > 1e-9 + }) { + pairs += 1; + let predicted = if b.short { + a.reference() * (1.0 + b.far / 100.0) + } else { + a.reference() * (1.0 - b.far / 100.0) + }; + reached += usize::from(reaches(a.extreme, predicted, !b.short)); + ref_err.push(((a.reference() - b.reference()) / b.reference() * 100.0).abs()); + level_err.push(((predicted - b.level) / b.level * 100.0).abs()); + match b.model_err { + Some(e) => model_err.push(e), + None => model_unfilled += 1, + } + } + } + let median = |v: &mut Vec| { + v.sort_by(f64::total_cmp); + v.get(v.len() / 2).copied() + }; + let within = |v: &[f64], tol: f64| v.iter().filter(|e| **e <= tol).count(); + eprintln!( + "cross-core same spike: {pairs} pairs · the first core's spike reached the second's predicted level {reached} · reference |err| median {:?} % · shift from the first core: level |err| median {:?} %, within 0.1 % {} · the model on the second's own tape: fill |err| median {:?} %, within 0.1 % {}, unfilled {model_unfilled}", + median(&mut ref_err), + median(&mut level_err), + within(&level_err, 0.1), + median(&mut model_err), + within(&model_err, 0.1), + ); + for (i, name) in ["model", "shift"].iter().enumerate() { + let errors = &mut partner_tally.errors[i]; + eprintln!( + "cross-core fill of the other core, {name}: {} pairs · filled {} · |err| median {:?} % · within 0.05 % {} · within 0.1 % {} · within 0.3 % {}", + partner_tally.pairs, + partner_tally.filled[i], + median(errors), + within(errors, 0.05), + within(errors, 0.1), + within(errors, 0.3), + ); + } print_delta_quality(&tracks, no_track); let mut cores: Vec<_> = per_core_short.into_iter().collect(); cores.sort_by_key(|(_, v)| std::cmp::Reverse(v.len())); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 11147248..b36dfeb8 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -18,7 +18,7 @@ use moon_core::db::tuner::VarStats; use moon_core::db::tuner::threshold_search::SearchHandle; use moon_core::db::tuner::ticks::params::ParamGroup; use moon_core::db::tuner::ticks::search::SearchResult; -use moon_core::db::tuner::ticks::{Deal, Verdict, fit_for_search}; +use moon_core::db::tuner::ticks::{Deal, EntryMethod, Verdict, fit_for_search}; use moon_core::feed::types::Tick; use moon_core::market::trade_replay::TickStatus; @@ -273,6 +273,9 @@ pub(in crate::analytics) struct TicksState { /// Which groups the search may vary. pub(in crate::analytics::tuner) vary_entry: bool, pub(in crate::analytics::tuner) vary_exit: bool, + /// How a MoonShot variant's entry is replayed, by the search and by the variant columns alike. + /// No control sets it yet: the corridor model, as before the choice existed. + pub(in crate::analytics::tuner) entry_method: EntryMethod, /// Fields held at their base value by the search. pub(in crate::analytics::tuner) locked: HashSet, /// The search settings, as typed. @@ -335,6 +338,7 @@ impl Default for TicksState { inputs: HashMap::new(), vary_entry: true, vary_exit: true, + entry_method: EntryMethod::default(), locked: HashSet::new(), iters: String::new(), min_trades: String::new(), diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 1f1c5ab5..ad8b7673 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -117,6 +117,7 @@ impl AnalyticsView { let changes: Vec> = (0..N_VAR).map(|i| self.ticks.variant_changes(i)).collect(); let defaults = self.filter_defaults(cx); + let entry_method = self.ticks.entry_method; let n = deals.len(); self.spawn_latest_db( &[ReadLane::TicksVariants], @@ -136,6 +137,7 @@ impl AnalyticsView { &kind, values, DEFAULT_LATENCY_MS, + entry_method, ); Some(stats_of(tally, spent)) }) @@ -244,6 +246,7 @@ impl AnalyticsView { .ok() .filter(|n| *n > 0); let train_frac = super::super::filter::state::train_frac(self.ticks.train_pct); + let entry_method = self.ticks.entry_method; let handle = SearchHandle::new(); self.ticks.sugg = SuggState::Running { handle: handle.clone(), @@ -269,6 +272,7 @@ impl AnalyticsView { seed: None, train_frac, latency_ms: DEFAULT_LATENCY_MS, + entry_method, }; suggest(&deals, ¶ms, &handle) }, From 7e3372c0d47a332fd8c30c00f8fdcb3ace248cac Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 19:56:50 +0200 Subject: [PATCH 27/51] feat(tuner): give the Entry/Exit axis the By-filter panel and settings, and stop re-judging it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/config/layout.rs | 29 + crates/moon-core/src/config/layout/tests.rs | 40 + crates/moon-core/src/config/mod.rs | 6 +- .../moon-core/src/db/tuner/ticks/calibrate.rs | 2 +- crates/moon-core/src/db/tuner/ticks/exit.rs | 28 +- crates/moon-core/src/db/tuner/ticks/line.rs | 62 +- .../src/db/tuner/ticks/line/tests.rs | 22 +- crates/moon-core/src/db/tuner/ticks/mod.rs | 8 + crates/moon-core/src/db/tuner/ticks/mshot.rs | 71 +- crates/moon-core/src/db/tuner/ticks/params.rs | 16 +- crates/moon-core/src/db/tuner/ticks/record.rs | 6 +- .../src/db/tuner/ticks/record/tests.rs | 7 +- crates/moon-core/src/db/tuner/ticks/search.rs | 67 +- .../src/db/tuner/ticks/search/tests.rs | 68 +- .../moon-core/src/db/tuner/ticks/settings.rs | 134 ++++ .../src/db/tuner/ticks/settings/tests.rs | 63 ++ crates/moon-core/src/db/tuner/ticks/tests.rs | 70 +- .../src/db/tuner/ticks/tests/real_data.rs | 37 +- crates/moon-core/src/db/tuner/ticks/verify.rs | 90 ++- crates/moon-ui-gpui/src/analytics/mod.rs | 9 +- .../moon-ui-gpui/src/analytics/tuner/kpi.rs | 20 +- .../moon-ui-gpui/src/analytics/tuner/mod.rs | 2 +- .../moon-ui-gpui/src/analytics/tuner/shell.rs | 32 +- .../src/analytics/tuner/ticks/cfg.rs | 748 ++++++++++++++++++ .../src/analytics/tuner/ticks/fetch.rs | 79 +- .../analytics/tuner/ticks/fetch/autoload.rs | 81 +- .../tuner/ticks/fetch/autoload/tests.rs | 45 ++ .../src/analytics/tuner/ticks/fetch/job.rs | 1 + .../src/analytics/tuner/ticks/grid.rs | 632 ++++++++------- .../src/analytics/tuner/ticks/lags.rs | 13 +- .../src/analytics/tuner/ticks/load.rs | 187 +++-- .../src/analytics/tuner/ticks/mod.rs | 205 ++--- .../src/analytics/tuner/ticks/model_cfg.rs | 228 ++++++ .../analytics/tuner/ticks/model_cfg/tests.rs | 30 + .../src/analytics/tuner/ticks/rows/tests.rs | 102 ++- .../src/analytics/tuner/ticks/state.rs | 204 +++-- .../src/analytics/tuner/ticks/variants.rs | 147 +++- .../tests/theme_contract/theme.rs | 7 + locales/analytics.yml | 256 +++++- 39 files changed, 2948 insertions(+), 906 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/settings.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/settings/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/model_cfg.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/model_cfg/tests.rs diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index d06d3cad..37259ed7 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -624,6 +624,29 @@ pub struct TableSortPreference { pub ascending: bool, } +/// The "Entry/Exit" tuner's persisted settings ([`WindowLayout::analytics_ticks`]). Every field +/// has a default, so a block missing any of them reads the rest. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TicksAxisLayout { + /// Restart count of the search; `None` = the axis default. + pub iters: Option, + /// Percentage of the period the search may fit on; `None` = the whole period. + pub train: Option, + /// Base seed of the restarts, as text (see [`WindowLayout::analytics_tuner_seed`]); `None` + /// draws one per search. + pub seed: Option, + /// Passes of coordinate descent per restart; `None` = the search's default. + pub passes: Option, + /// The share of reproduced trades, per cent, a parameter group needs before it may be + /// searched; `None` = the axis default. + pub gate_pct: Option, + /// Strategy fields the search holds at their base value — the unticked grid rows. + pub locked: Vec, + /// The model's own settings. + pub model: crate::db::tuner::ticks::ModelSettings, +} + /// Complete window layout. /// /// Every field is `Option` or carries `#[serde(default)]` on purpose, and prefers a type wider @@ -1115,6 +1138,12 @@ pub struct WindowLayout { /// file. #[serde(default, deserialize_with = "de_lenient_bool")] pub analytics_tuner_compose: bool, + /// The "Entry/Exit" tuner's settings: its search's and its model's. `None` — every config + /// written before the axis had settings — opens on the defaults. Read leniently: the block + /// is hand-editable, and a malformed one must cost only itself, never the window positions + /// around it. + #[serde(default, deserialize_with = "de_lenient")] + pub analytics_ticks: Option, /// Visible screener columns (keys in canonical order). None = all. #[serde(default)] pub screener_columns: Option>, diff --git a/crates/moon-core/src/config/layout/tests.rs b/crates/moon-core/src/config/layout/tests.rs index 8bffeed7..f979d5a4 100644 --- a/crates/moon-core/src/config/layout/tests.rs +++ b/crates/moon-core/src/config/layout/tests.rs @@ -2316,3 +2316,43 @@ fn a_hand_written_figure_alert_setting_cannot_discard_the_saved_layout() { assert_eq!(resolve_alert_repeat(Some(0)), 0); assert_eq!(resolve_alert_repeat(Some(9_999)), ALERT_REPEAT_MAX); } + +/// The Entry/Exit axis' settings survive their own round trip, a config without them opens on +/// the defaults, and a block written wrong costs only itself. +#[test] +fn the_ticks_axis_settings_round_trip_and_never_cost_the_layout() { + let saved = WindowLayout { + analytics_ticks: Some(TicksAxisLayout { + iters: Some(40), + locked: vec!["SellPrice".to_string()], + model: crate::db::tuner::ticks::ModelSettings { + latency_ms: 250.0, + entry_method: crate::db::tuner::ticks::EntryMethod::Shift, + ..Default::default() + }, + ..TicksAxisLayout::default() + }), + ..WindowLayout::default() + }; + let encoded = toml::to_string(&saved).expect("the layout must serialize"); + let decoded: WindowLayout = toml::from_str(&encoded).expect("its own output must load back"); + assert_eq!(decoded.analytics_ticks, saved.analytics_ticks); + + let old: WindowLayout = toml::from_str("analytics_period = \"p-cur-month\"\n").unwrap(); + assert_eq!(old.analytics_ticks, None); + + let partial: WindowLayout = + toml::from_str("[analytics_ticks.model]\nlatency_ms = 150.0\n").expect("a partial block"); + let model = partial.analytics_ticks.expect("the block").model; + assert_eq!(model.latency_ms, 150.0); + assert_eq!( + model.ticker_period_ms, + crate::db::tuner::ticks::line::TICKER_PERIOD_MS + ); + + let broken: WindowLayout = + toml::from_str("analytics_period = \"p-cur-month\"\n[analytics_ticks]\niters = \"many\"\n") + .expect("a malformed block must not reject the document"); + assert_eq!(broken.analytics_period.as_deref(), Some("p-cur-month")); + assert_eq!(broken.analytics_ticks, None); +} diff --git a/crates/moon-core/src/config/mod.rs b/crates/moon-core/src/config/mod.rs index 691af01e..b129d0f5 100644 --- a/crates/moon-core/src/config/mod.rs +++ b/crates/moon-core/src/config/mod.rs @@ -95,9 +95,9 @@ pub use layout::{ ALERT_DURATION_S_DEFAULT, ALERT_DURATION_S_MAX, ALERT_DURATION_S_MIN, ALERT_REPEAT_DEFAULT, ALERT_REPEAT_MAX, AUTO_WORKSPACE_RAIL_WIDTH_DEFAULT, AUTO_WORKSPACE_RAIL_WIDTH_MAX, AUTO_WORKSPACE_RAIL_WIDTH_MIN, ChartGraphicsCfg, DetachedLayout, GeomRect, GroupLayout, - HVOL_TF_MAX_S, HvolSide, ReportFilterPrefs, TableSortPreference, TradeHistoryStyle, - WindowLayout, WorkspaceMode, clamp_auto_workspace_rail_width, resolve_alert_duration_s, - resolve_alert_repeat, + HVOL_TF_MAX_S, HvolSide, ReportFilterPrefs, TableSortPreference, TicksAxisLayout, + TradeHistoryStyle, WindowLayout, WorkspaceMode, clamp_auto_workspace_rail_width, + resolve_alert_duration_s, resolve_alert_repeat, }; pub use news_tags::NewsTagSettings; pub use orders::{LineStyle, OrdersStyle, OrdersStyleSet}; diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate.rs b/crates/moon-core/src/db/tuner/ticks/calibrate.rs index 1539d4f4..ce06efb5 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate.rs @@ -39,7 +39,7 @@ pub fn step_lag_samples(deal: &Deal, exit: &ExitParams, exit_points: &[(i64, f64 // The fill point the way the verdict tells it (`verify::ArchivedExit`): a fill through the // market lands well past the sale's tolerance of its level and is still no step. let moves = ArchivedExit::of(deal, exit, exit_points).moves; - let delay_ms = step_ms(exit.price_down_delay_s); + let delay_ms = step_ms(exit.price_down_delay_s, exit.model.step_floor_ms); // From the second move on: the first is the take, and the step after it is timed off the // take by `PriceDownTimer`, not off a step before it. moves diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index b3aae327..d1057226 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -10,15 +10,18 @@ //! raised by `MShotSellAtLastPrice` to //! the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the 4-second-old ASK, i.e. before //! the spike"; the model takes the ask the caller recovered from the order archive -//! (`Deal::pre_spike_ask`), else reads the last print at least [`PRE_SPIKE_LOOKBACK_MS`] before -//! the fill, since the tape has no book). From there the line moves under the strategy's sell rules +//! (`Deal::pre_spike_ask`), else reads the last print at least +//! `ModelSettings::pre_spike_lookback_ms` ([`super::mshot::PRE_SPIKE_LOOKBACK_MS`] by default) +//! before the fill, since the tape has no book). From there the line moves under the strategy's +//! sell rules //! — `PriceDown*`, `SellLevel*`, `SellShot*` — and the stop fires under `StopLoss*`; see //! [`super::line`]. A position nothing closed inside the tape is [`ExitKind::OpenAtWindowEnd`]: //! not a trade, whatever the core's exit was. use super::hook::{KIND_MOONHOOK, hook_take_pct}; use super::line::{LineWalk, walk, walk_held}; -use super::mshot::{DEFAULT_LATENCY_MS, Modifiers, PRE_SPIKE_LOOKBACK_MS}; +use super::mshot::Modifiers; +use super::settings::ModelSettings; use super::{Deal, Exit, Fill}; use crate::feed::types::Tick; @@ -127,8 +130,9 @@ pub struct ExitParams { /// search (`record::fit_for_search`): a variant's exit there is whatever the missing rule /// would have made of it. pub unmodelled: Option, - /// Model parameter: how long a replacement of the sell takes to reach the book. - pub latency_ms: f64, + /// The model's own settings — the sell's replacement latency, the stop's clocks, the + /// verdict's tolerances; not strategy fields. + pub model: ModelSettings, /// Verdict-only: start the line at the archived take (`Deal::archived_take`) for a kind /// whose take rule the model does not compute itself. Off for every variant, whose take /// comes from the RULES — `SellPrice`, or `HookSellLevel · depth` for a MoonHook — so that @@ -184,7 +188,7 @@ impl Default for ExitParams { fast_stop_loss: true, stop_loss_ema: 0.0, unmodelled: None, - latency_ms: DEFAULT_LATENCY_MS, + model: ModelSettings::default(), take_from_archive: false, } } @@ -248,7 +252,9 @@ impl<'a> ExitModel<'a> { let pre = deal .pre_spike_ask .filter(|p| p.is_finite() && *p > 0.0) - .or_else(|| pre_spike_price(ticks, fill.t_ms)); + .or_else(|| { + pre_spike_price(ticks, fill.t_ms, self.params.model.pre_spike_lookback_ms) + }); if let (Some(pre), Some(factor)) = (pre, ask_take_factor(self.params, deal.is_short)) { take = if deal.is_long() { take.max(pre * factor) @@ -479,10 +485,10 @@ pub fn archived_pre_spike_ask( (take.is_finite() && take > 0.0).then_some(take / factor) } -/// The last print at least [`PRE_SPIKE_LOOKBACK_MS`] before `at_ms` — the FAQ's "price before -/// the spike". -pub fn pre_spike_price(ticks: &[Tick], at_ms: i64) -> Option { - let cutoff = at_ms - PRE_SPIKE_LOOKBACK_MS; +/// The last print at least `lookback_ms` ([`super::mshot::PRE_SPIKE_LOOKBACK_MS`] by default) +/// before `at_ms` — the FAQ's "price before the spike". +pub fn pre_spike_price(ticks: &[Tick], at_ms: i64, lookback_ms: i64) -> Option { + let cutoff = at_ms - lookback_ms; ticks .iter() .rev() diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs index 7f410338..626e48ec 100644 --- a/crates/moon-core/src/db/tuner/ticks/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/line.rs @@ -170,6 +170,8 @@ struct BookStop { proxy: Option, avg: Option, next_sample: i64, + /// The ticker's period (`ModelSettings::ticker_period_ms`), at least a millisecond. + period_ms: i64, /// Up to when the fact proves this stop did not fire (`record::StopAnchor`): a sample by /// then is averaged but cannot fire. `i64::MIN` when the walk is not the trade's own stop. quiet_until: i64, @@ -185,14 +187,17 @@ struct SeriesPoint { candidate: Option, /// When the open tick closes; every pending print is before it. tick_end: i64, + /// The tick's length (`ModelSettings::series_tick_ms`), at least a millisecond. + tick_ms: i64, } impl SeriesPoint { - fn new(first_ms: i64) -> Self { + fn new(first_ms: i64, tick_ms: i64) -> Self { Self { point: None, candidate: None, - tick_end: next_series_tick(first_ms), + tick_end: next_series_tick(first_ms, tick_ms), + tick_ms, } } @@ -208,10 +213,12 @@ impl SeriesPoint { } } -/// The end of the series tick a print at `t_ms` falls in: the ticks run on the clock's 250 ms -/// boundaries, and a print ON a boundary opens the next tick. -fn next_series_tick(t_ms: i64) -> i64 { - (t_ms.div_euclid(SERIES_TICK_MS) + 1) * SERIES_TICK_MS +/// The end of the series tick a print at `t_ms` falls in: the ticks run on the clock's +/// `tick_ms` boundaries ([`SERIES_TICK_MS`] by default), and a print ON a boundary opens the next +/// tick. +fn next_series_tick(t_ms: i64, tick_ms: i64) -> i64 { + let tick_ms = tick_ms.max(1); + (t_ms.div_euclid(tick_ms) + 1) * tick_ms } impl BookStop { @@ -235,8 +242,9 @@ impl BookStop { ) -> Self { // One period past the fill, and back from there in whole periods: the ticker's phase // is on no record, so the fill anchors it, however far back the tape reaches. - let anchor = fill_ms + TICKER_PERIOD_MS; - let back = (anchor - first_ms).max(0) / TICKER_PERIOD_MS; + let period_ms = params.model.ticker_period_ms.max(1); + let anchor = fill_ms + period_ms; + let back = (anchor - first_ms).max(0) / period_ms; Self { long, level, @@ -245,9 +253,11 @@ impl BookStop { weight: stop_average_weight(params, long), proxy: None, avg: None, - next_sample: anchor - back * TICKER_PERIOD_MS, + next_sample: anchor - back * period_ms, + period_ms, quiet_until, - series: (params.stop_loss_ema.abs() < 1e-9).then(|| SeriesPoint::new(first_ms)), + series: (params.stop_loss_ema.abs() < 1e-9) + .then(|| SeriesPoint::new(first_ms, params.model.series_tick_ms)), } } @@ -273,7 +283,7 @@ impl BookStop { fn samples_before(&mut self, until: i64) -> Option { while self.next_sample < until { let at = self.next_sample; - self.next_sample += TICKER_PERIOD_MS; + self.next_sample += self.period_ms; let Some(bid) = self.proxy else { continue; }; @@ -302,7 +312,7 @@ impl BookStop { return None; } let at = series.tick_end; - series.tick_end = next_series_tick(until); + series.tick_end = next_series_tick(until, series.tick_ms); let point = series.candidate.take()?; series.point = Some(point); let past = if self.long { @@ -378,10 +388,11 @@ fn advance(core: &mut f64, last_sent: &mut f64, level: f64, order: f64) -> bool true } -/// Seconds to milliseconds, with the terminal's floor for a zero delay. -pub(super) fn step_ms(seconds: f64) -> i64 { +/// Seconds to milliseconds, with the terminal's floor (`ModelSettings::step_floor_ms`, +/// [`STEP_FLOOR_MS`] by default) for a zero delay. +pub(super) fn step_ms(seconds: f64, floor_ms: i64) -> i64 { let ms = (seconds * 1000.0) as i64; - if ms <= 0 { STEP_FLOOR_MS } else { ms } + if ms <= 0 { floor_ms } else { ms } } /// Walk the tape after the fill under `params`, starting from the take `take`. @@ -414,7 +425,8 @@ pub fn walk_held( let side = Side { long: deal.is_long(), }; - let latency_ms = params.latency_ms.max(0.0) as i64; + let latency_ms = params.model.latency_whole_ms(); + let floor_ms = params.model.step_floor_ms; let armed_at = fill.t_ms + params.sell_delay_ms.max(0.0) as i64; // When the take is on the book: placed at `armed_at`, there after the same latency as any // move of the line. @@ -469,15 +481,16 @@ pub fn walk_held( let pd_floor = side.over(fill.price, params.price_down_allowed_drop_pct); // --- PumpMove --- one move, timed off the take (see `PUMP_MOVE_LAG_MS`). - let mut pm_next = (params.pump_move_timer_s > 0.0) - .then(|| armed_at + (params.pump_move_timer_s * 1000.0) as i64 + PUMP_MOVE_LAG_MS); + let mut pm_next = (params.pump_move_timer_s > 0.0).then(|| { + armed_at + (params.pump_move_timer_s * 1000.0) as i64 + params.model.pump_move_lag_ms + }); // --- SellLevel --- let sl_on = params.sell_level_delay_s != 0.0 && params.sell_level_time_s > 0.0 && params.sell_level_count > 0; let sl_first_ms = if params.sell_level_delay_s < 0.0 { - STEP_FLOOR_MS + floor_ms } else { (params.sell_level_delay_s * 1000.0) as i64 }; @@ -489,9 +502,9 @@ pub fn walk_held( let sl_step_ms = if params.sell_level_delay_next_s > 0.0 { (params.sell_level_delay_next_s * 1000.0) as i64 } else if params.sell_level_delay_next_s < 0.0 { - STEP_FLOOR_MS + floor_ms } else { - sl_first_ms.max(STEP_FLOOR_MS) + sl_first_ms.max(floor_ms) }; let mut sl_left = params.sell_level_count; let sl_until = if params.sell_level_work_time_s > 0.0 { @@ -504,6 +517,9 @@ pub fn walk_held( // --- SellShot --- let ss_on = !params.ignore_sell_shot && params.sell_shot_distance_pct != 0.0; let ss_from = fill.t_ms + (params.sell_shot_delay_s.max(0.0) * 1000.0) as i64; + // The core's own floor on the SellShot calculation window — the same 100 ms its fast + // algorithm reads, but a rule of the sell, not the entry's re-place window + // (`ModelSettings::replace_window_ms`), and not a setting of the model. let ss_calc_ms = ((params.sell_shot_calc_interval_s.max(0.0) * 1000.0) as i64).max(FAST_ALGO_WINDOW_MS); let ss_low = side.over(fill.price, params.sell_shot_allowed_down_pct); @@ -589,7 +605,7 @@ pub fn walk_held( let pm_due = pm_next.filter(|due| t_ms >= *due); if let Some(due) = pm_due.filter(|pm| pd_due.is_none_or(|pd| *pm <= pd)) { pm_next = None; - let from = armed_at - PUMP_PEAK_LOOKBACK_MS; + let from = armed_at - params.model.pump_peak_lookback_ms; let peak = side.extreme( ticks[..=index] .iter() @@ -628,7 +644,7 @@ pub fn walk_held( } else { 0 }; - pd_next = Some(due + step_ms(params.price_down_delay_s) + lag_ms); + pd_next = Some(due + step_ms(params.price_down_delay_s, floor_ms) + lag_ms); } // SellLevel: to the high of the look-back, adjusted. while let Some(due) = sl_next.filter(|due| t_ms >= *due) { diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs index 9c9ad760..225934bb 100644 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/line/tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::db::tuner::ticks::exit::{ExitModel, archived_pre_spike_ask}; -use crate::db::tuner::ticks::{Deltas, EntryParams, verify}; +use crate::db::tuner::ticks::{Deltas, EntryParams, ModelSettings, verify}; use crate::feed::types::Side as TickSide; fn tick(t_ms: i64, price: f64) -> Tick { @@ -61,7 +61,10 @@ fn fill() -> Fill { /// A 1 % take, no latency, and the rule under test. fn params() -> ExitParams { ExitParams { - latency_ms: 0.0, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, ..ExitParams::default() } } @@ -597,7 +600,10 @@ fn verify_judges_a_book_stop_by_its_level_and_moment() { #[test] fn the_take_is_on_the_book_only_after_the_latency() { let p = ExitParams { - latency_ms: 100.0, + model: ModelSettings { + latency_ms: 100.0, + ..ModelSettings::default() + }, ..ExitParams::default() }; let ticks = tape(&[(9, 101.5), (150, 101.2)]); @@ -659,7 +665,10 @@ fn a_spike_through_the_old_level_fills_before_a_step_lands() { let p = ExitParams { price_down_timer_s: 1.0, price_down_pct: 50.0, - latency_ms: 100.0, + model: ModelSettings { + latency_ms: 100.0, + ..ModelSettings::default() + }, ..params() }; // The step is decided at t=1000 and reaches the book at t=1100; a print at 101 at t=1050 @@ -832,7 +841,10 @@ fn clock_params(take_pct: f64) -> ExitParams { price_down_pct: 50.0, price_down_delay_s: 1.0, price_down_allowed_drop_pct: 0.1, - latency_ms: 100.0, + model: ModelSettings { + latency_ms: 100.0, + ..ModelSettings::default() + }, ..params() } } diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 00f5cbb3..5a46fcfc 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -37,6 +37,7 @@ pub mod params; pub mod record; pub mod scope; pub mod search; +pub mod settings; pub mod stats; pub mod verify; @@ -49,6 +50,7 @@ pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; pub use record::{OwnLines, StopAnchor, entry_placement, fit_for_search, prepare_deal}; pub use scope::{is_service_row, is_tunable}; pub use search::{PreparedDeal, SearchParams, SearchResult, suggest, variant_tally}; +pub use settings::ModelSettings; pub use stats::{fact_stats, stats_of}; pub use verify::{Verdict, verify}; @@ -405,6 +407,12 @@ impl Outcome { } /// The entry-side parameters of one variant: the strategy kind's own model, or the fact. +/// +/// The MoonShot variant is the large one (its parameters carry the model's settings); left +/// unboxed on purpose — one lives per deal (`Deal::own_entry`) and one per scored point, never +/// in a collection large enough for the size to matter, and a box would put an allocation on +/// every point of the search. +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq)] pub enum EntryParams { /// Take the entry as the report has it — for kinds without a model, and for a variant diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index d197af96..67b7020b 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -71,6 +71,7 @@ //! fact ([`EntryMethod`]): the fact's order where it stood at the spike, moved by the variant's //! far bound, with no path of its own. +use super::settings::ModelSettings; use super::verify::archived_replacements; use super::{Deal, Deltas, EntryParams, Fill, deltas, reaches, snap_to_step}; use crate::feed::types::{Side, Tick}; @@ -105,7 +106,7 @@ impl UsePrice { /// cores with different `MShotPrice` filled on the same spike — predicting the second core's /// fill from the first's tape and record: the model placed it a median 0.156 % off (99 within /// 0.1 %) and filled 264; the shift 0.078 % off (141 within 0.1 %) and filled 250. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum EntryMethod { /// The corridor model from the order's creation ([`MshotEntry::fill`] via `run`): the whole /// path the variant's order would have walked. The one that answers for `MShotRaiseWait`, @@ -120,6 +121,26 @@ pub enum EntryMethod { Shift, } +impl EntryMethod { + /// The strategy fields that change only the order's PATH — which a shift does not replay. + const PATH_ONLY: [&'static str; 4] = [ + "MShotUsePrice", + "MShotRaiseWait", + "MShotReplaceDelay", + "FastShotAlgo", + ]; + + /// Whether a replay by this method reads the strategy field `key` at all: a field it does + /// not read moves no column, so the grid greys it out and the search leaves it alone. Every + /// field that is not an entry field is read by both. + pub fn reads(self, key: &str) -> bool { + match self { + Self::Model => true, + Self::Shift => !Self::PATH_ONLY.contains(&key), + } + } +} + /// How a family of modifiers reads the market-wide deltas. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MarketSign { @@ -239,20 +260,18 @@ pub struct MshotParams { /// same corridor either way (see the module doc). pub fast_algo: bool, pub modifiers: Modifiers, - /// Model parameter, not a strategy field: how long a replacement takes to reach the book. - pub latency_ms: f64, - /// Model parameter, not a strategy field: how a variant's entry is replayed. - pub method: EntryMethod, + /// The model's own settings, not strategy fields: the replacement latency, the replay + /// method, the windows. + pub model: ModelSettings, } impl MshotParams { /// Whether two parameter sets are the same strategy — every strategy field equal, whatever - /// the model parameters (`latency_ms`, `method`) say. + /// the model's settings say. pub fn same_strategy(&self, other: &Self) -> bool { *self == Self { - latency_ms: self.latency_ms, - method: self.method, + model: self.model, ..other.clone() } } @@ -271,8 +290,7 @@ impl Default for MshotParams { minus_satoshi: false, fast_algo: false, modifiers: Modifiers::default(), - latency_ms: DEFAULT_LATENCY_MS, - method: EntryMethod::Model, + model: ModelSettings::default(), } } } @@ -307,7 +325,7 @@ impl<'a> MshotEntry<'a> { /// How these parameters want a variant's entry replayed. pub fn method(&self) -> EntryMethod { - self.params.method + self.params.model.entry_method } /// Where the order would stand for a reference price: the far bound away, kept two steps @@ -432,7 +450,8 @@ impl<'a> MshotEntry<'a> { /// The entry as a SHIFT of the fact: the variant's order at [`Self::anchored_level`] from the /// moment the fact's order last moved — standing where the fact's stood, one far bound deeper /// or shallower, since the same moment — filled by the first print that reaches it by - /// [`SHIFT_WINDOW_MS`] past the buy: the same spike. Nothing about the corridor is modelled: + /// the shift window past the buy (`ModelSettings::shift_window_ms`, [`SHIFT_WINDOW_MS`] by + /// default): the same spike. Nothing about the corridor is modelled: /// the order is taken to stand still through the spike, as the fact's did. pub(super) fn shifted_fill( &self, @@ -447,7 +466,7 @@ impl<'a> MshotEntry<'a> { .iter() .map(|t| (t.time_ms as i64, f64::from(t.price))) .filter(|&(t, p)| t >= since && p > 0.0) - .take_while(|&(t, _)| t <= deal.buy_ms + SHIFT_WINDOW_MS) + .take_while(|&(t, _)| t <= deal.buy_ms + self.params.model.shift_window_ms) .find(|&(_, p)| reaches(p, level, deal.is_long())) .map(|(t_ms, _)| Fill { t_ms, price: level }) } @@ -518,9 +537,13 @@ impl<'a> MshotEntry<'a> { let mut bounds = LiveBounds::new(self.params, deal); let raise_wait_ms = (self.params.raise_wait_s * 1000.0).max(0.0); let replace_delay_ms = (self.params.replace_delay_s * 1000.0).max(0.0); - let latency_ms = self.params.latency_ms.max(0.0); + let latency_ms = self.params.model.latency_ms.max(0.0); - let mut reference = Reference::new(self.params.use_price, deal.is_long()); + let mut reference = Reference::new( + self.params.use_price, + deal.is_long(), + self.params.model.replace_window_ms, + ); let first_print_ms = ticks[0].time_ms as i64; let mut index = 0; @@ -740,12 +763,15 @@ fn retreat_pct(near_pct: f64, far_pct: f64) -> f64 { /// for both, and a run-away holds for `MShotRaiseWait` exactly when the price stayed away that /// long — the timer in [`MshotEntry::run`]; neither looks at a window. /// - [`Self::placement`], what a re-placed order is put off: the extreme of the wanted side's -/// prints inside the last [`FAST_ALGO_WINDOW_MS`] — the lowest for a long — whatever -/// `MShotRaiseWait` is. The core takes the minimum of the trades of the last ~75–150 ms, so a -/// spike's own low prints place the order below it rather than off the rebound. +/// prints inside the last `window_ms` ([`FAST_ALGO_WINDOW_MS`] by default) — the lowest for a +/// long — whatever `MShotRaiseWait` is. The core takes the minimum of the trades of the last +/// ~75–150 ms, so a spike's own low prints place the order below it rather than off the +/// rebound. struct Reference { wanted_side: Option, is_long: bool, + /// The placement window (`ModelSettings::replace_window_ms`). + window_ms: i64, last_any: Option, last_side: Option, /// `(t_ms, price)` of the wanted side's prints inside the window, oldest first. @@ -753,7 +779,7 @@ struct Reference { } impl Reference { - fn new(use_price: UsePrice, is_long: bool) -> Self { + fn new(use_price: UsePrice, is_long: bool, window_ms: i64) -> Self { Self { wanted_side: match use_price { UsePrice::Trade => None, @@ -761,6 +787,7 @@ impl Reference { UsePrice::Bid => Some(Side::Sell), }, is_long, + window_ms, last_any: None, last_side: None, recent: std::collections::VecDeque::new(), @@ -780,7 +807,7 @@ impl Reference { while self .recent .front() - .is_some_and(|(t, _)| t_ms - *t > FAST_ALGO_WINDOW_MS) + .is_some_and(|(t, _)| t_ms - *t > self.window_ms) { self.recent.pop_front(); } @@ -796,7 +823,7 @@ impl Reference { } /// What a re-placed order is put off, deciding at `now_ms`: the extreme of the wanted side's - /// prints of the last [`FAST_ALGO_WINDOW_MS`] before it, else the check price. The window is + /// prints of the last `window_ms` before it, else the check price. The window is /// pruned only when that side prints, so it is read against the decision's own moment: a /// burst an ASK / BID side printed seconds ago is not "the last 100 ms". Its fallback is still /// that side's last print, however old — the tape has no book, and the corridor is measured @@ -805,7 +832,7 @@ impl Reference { let prices = self .recent .iter() - .filter(|(t, _)| now_ms - *t <= FAST_ALGO_WINDOW_MS) + .filter(|(t, _)| now_ms - *t <= self.window_ms) .map(|(_, p)| *p); let extreme = if self.is_long { prices.reduce(f64::min) diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 11e87e7a..0c81d4f0 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -12,6 +12,7 @@ use std::collections::HashMap; use super::exit::{ExitParams, UnmodelledRule}; use super::mshot::{MarketSign, Modifiers, MshotParams, UsePrice}; +use super::settings::ModelSettings; /// Which group of the grid a parameter belongs to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -470,10 +471,8 @@ fn parse_num(s: &str) -> Option { .filter(|v| v.is_finite()) } -/// MoonShot entry parameters out of a strategy's values; `latency_ms` is the model's own, and -/// the entry is replayed by the corridor model ([`super::mshot::EntryMethod::Model`]) unless the -/// caller sets another. -pub fn mshot_params(v: &StrategyValues<'_>, latency_ms: f64) -> MshotParams { +/// MoonShot entry parameters out of a strategy's values, under the model's own settings. +pub fn mshot_params(v: &StrategyValues<'_>, model: ModelSettings) -> MshotParams { let base = MshotParams::default(); MshotParams { price_pct: v.num("MShotPrice", base.price_pct), @@ -505,13 +504,12 @@ pub fn mshot_params(v: &StrategyValues<'_>, latency_ms: f64) -> MshotParams { market_sign: MarketSign::Signed, distance_pct: v.num("MShotAddDistance", 0.0), }, - latency_ms, - method: base.method, + model, } } -/// Sell-line parameters out of a strategy's values; `latency_ms` is the model's own. -pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { +/// Sell-line parameters out of a strategy's values, under the model's own settings. +pub fn exit_params(v: &StrategyValues<'_>, model: ModelSettings) -> ExitParams { let base = ExitParams::default(); ExitParams { sell_price_pct: v.num("SellPrice", base.sell_price_pct), @@ -593,7 +591,7 @@ pub fn exit_params(v: &StrategyValues<'_>) -> ExitParams { fast_stop_loss: v.bool("FastStopLoss", false), stop_loss_ema: v.num("StopLossEMA", base.stop_loss_ema), unmodelled: unmodelled_rule(v), - latency_ms: base.latency_ms, + model, take_from_archive: base.take_from_archive, } } diff --git a/crates/moon-core/src/db/tuner/ticks/record.rs b/crates/moon-core/src/db/tuner/ticks/record.rs index e2d8c126..bb6ea5df 100644 --- a/crates/moon-core/src/db/tuner/ticks/record.rs +++ b/crates/moon-core/src/db/tuner/ticks/record.rs @@ -90,7 +90,7 @@ impl StopAnchor { // The stop the fact ran is the one its own fill placed: read at the fact's moment, a // walk filling a few milliseconds off it is still the same stop. fill.price == self.entry_price - && (fill.t_ms - self.entry_ms).abs() <= POINT_TIME_TOLERANCE_MS + && (fill.t_ms - self.entry_ms).abs() <= params.model.point_time_ms && stop_pct(params, deal, self.entry_ms) == self.stop_pct && params.stop_loss_delay_s == self.delay_s && params.fast_stop_loss == self.fast @@ -148,6 +148,10 @@ pub fn entry_placement(deal: &Deal, lines: OwnLines<'_>) -> Option { let level = match lines.entry.filter(|l| !l.is_empty()) { Some(points) => { let &(first_ms, price) = points.iter().min_by_key(|(t, _)| *t)?; + // Two of the core's own records against each other — its creation stamp and its + // archive's first point — not the model against the fact, so the constant and not + // the verdict's setting (`ModelSettings::point_time_ms`): what the record says does + // not move with how strictly the model is judged. ((first_ms - created_ms).abs() <= POINT_TIME_TOLERANCE_MS).then_some(price)? } None if lines.answered => deal.buy_price, diff --git a/crates/moon-core/src/db/tuner/ticks/record/tests.rs b/crates/moon-core/src/db/tuner/ticks/record/tests.rs index e84f9bb7..577b42b7 100644 --- a/crates/moon-core/src/db/tuner/ticks/record/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -4,7 +4,7 @@ use super::*; use crate::db::tuner::ticks::line::walk; use crate::db::tuner::ticks::mshot::MshotParams; -use crate::db::tuner::ticks::{Deltas, ExitKind, simulate, verify}; +use crate::db::tuner::ticks::{Deltas, ExitKind, ModelSettings, simulate, verify}; use crate::feed::types::{Side, Tick}; fn tick(t_ms: i64, price: f64) -> Tick { @@ -66,7 +66,10 @@ fn book() -> ExitParams { ExitParams { stop_loss_pct: -1.0, fast_stop_loss: false, - latency_ms: 0.0, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, ..ExitParams::default() } } diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index a8641ece..4347bbfc 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -14,9 +14,11 @@ //! trade and drops out of `n`; the caller prints "by N of M" beside the column so a variant //! that wins by trading less is visible as such. //! -//! A MoonShot variant's entry is replayed the way the caller picks ([`EntryMethod`]): the corridor -//! model from the order's creation, or the fact's order shifted at the spike. The trade's own -//! settings take the fact's fill either way. +//! A MoonShot variant's entry is replayed the way the caller's model settings pick +//! ([`super::mshot::EntryMethod`]): the corridor model from the order's creation, or the fact's +//! order shifted at the spike. The trade's own settings take the fact's fill either way, and a +//! field the picked method does not read is not searched +//! ([`super::mshot::EntryMethod::reads`]). //! //! Chronological order is kept on purpose: the train/holdout cut and the drawdown read the //! SEQUENCE, and the deals arrive sorted by close from `read_deals`. @@ -26,18 +28,19 @@ use std::sync::Arc; use rayon::prelude::*; -use super::mshot::{EntryMethod, MshotParams}; use super::params::{ ParamGroup, ParamKind, StrategyValues, TICK_PARAMS, exit_params, mshot_params, }; +use super::settings::ModelSettings; use super::{Deal, EntryParams, ExitParams, entry_model_for, simulate}; use crate::db::metrics::Tally; use crate::db::tuner::threshold_search::search::{install, restart_seed}; use crate::db::tuner::threshold_search::{SearchHandle, train_split}; use crate::feed::types::Tick; -/// Passes of coordinate descent one restart may take before it is called converged. -const MAX_PASSES: usize = 16; +/// Passes of coordinate descent one restart may take before it is called converged, when the +/// caller does not say ([`SearchParams::max_passes`]). +pub const DEFAULT_MAX_PASSES: usize = 16; /// One deal with its tape, ready to be replayed as often as the search asks. #[derive(Clone)] @@ -112,10 +115,10 @@ pub struct SearchParams<'a> { pub seed: Option, /// Share of the period, oldest first, the search may fit on. pub train_frac: f64, - /// Replacement latency of the model, milliseconds. - pub latency_ms: f64, - /// How a MoonShot variant's entry is replayed. - pub entry_method: EntryMethod, + /// Passes of coordinate descent per restart, at least 1. + pub max_passes: usize, + /// The model's own settings, the entry method among them. + pub model: ModelSettings, } /// What the search found. @@ -140,8 +143,7 @@ fn params_of( defaults: &HashMap, point: &Point, kind: &str, - latency_ms: f64, - entry_method: EntryMethod, + model: ModelSettings, ) -> (EntryParams, ExitParams) { let mut values = base.clone(); for (key, value) in point { @@ -152,16 +154,11 @@ fn params_of( defaults, }; let entry = if entry_model_for(kind) { - EntryParams::MoonShot(MshotParams { - method: entry_method, - ..mshot_params(&sv, latency_ms) - }) + EntryParams::MoonShot(mshot_params(&sv, model)) } else { EntryParams::Fact }; - let mut exit = exit_params(&sv); - exit.latency_ms = latency_ms; - (entry, exit) + (entry, exit_params(&sv, model)) } /// The spelling of one grid value in the strategy's format. @@ -202,6 +199,8 @@ fn varied<'a>(p: &SearchParams<'a>) -> Vec<&'static super::params::TickParam> { // varied for nothing, land in a variant column the grid cannot show, and be written by // Save all the same. .filter(|f| !f.not_kinds.contains(&p.kind)) + // A field the entry method does not read moves nothing either. + .filter(|f| p.model.entry_method.reads(f.key)) .filter(|f| !p.locked.contains(f.key)) .collect() } @@ -288,15 +287,10 @@ pub fn suggest( | 1 }); let restarts = params.restarts.max(1); + let max_passes = params.max_passes.max(1); + let model = params.model.sanitized(); let evaluate = |point: &Point| -> Tally { - let (entry, exit) = params_of( - params.base, - params.defaults, - point, - params.kind, - params.latency_ms, - params.entry_method, - ); + let (entry, exit) = params_of(params.base, params.defaults, point, params.kind, model); tally(train, &entry, &exit) }; let best = install(|| { @@ -318,7 +312,7 @@ pub fn suggest( } } let mut score = evaluate(&point); - for _ in 0..MAX_PASSES { + for _ in 0..max_passes { let mut improved = false; for field in &fields { if handle.is_cancelled() { @@ -380,14 +374,7 @@ pub fn suggest( .collect(); values.sort(); let holdout = (train_n < deals.len()).then(|| { - let (entry, exit) = params_of( - params.base, - params.defaults, - &point, - params.kind, - params.latency_ms, - params.entry_method, - ); + let (entry, exit) = params_of(params.base, params.defaults, &point, params.kind, model); tally(&deals[train_n..], &entry, &exit) }); Some(SearchResult { @@ -407,16 +394,14 @@ pub fn suggest( /// defaults: Schema defaults. /// kind: The strategy kind. /// values: The variant's changes over the base, in strategy spelling. -/// latency_ms: Replacement latency of the model. -/// entry_method: How a MoonShot variant's entry is replayed. +/// model: The model's own settings, the entry method among them. pub fn variant_tally( deals: &[PreparedDeal], base: &HashMap, defaults: &HashMap, kind: &str, values: &[(String, String)], - latency_ms: f64, - entry_method: EntryMethod, + model: ModelSettings, ) -> (Tally, f64) { let mut point = Point::new(); for (key, value) in values { @@ -424,7 +409,7 @@ pub fn variant_tally( point.insert(field.key, value.clone()); } } - let (entry, exit) = params_of(base, defaults, &point, kind, latency_ms, entry_method); + let (entry, exit) = params_of(base, defaults, &point, kind, model.sanitized()); install(|| tally_and_spent(deals, &entry, &exit)) } diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index eccebb31..2f60f1f7 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -103,8 +103,11 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { min_n: Some(4), seed: Some(7), train_frac: 1.0, - latency_ms: 0.0, - entry_method: EntryMethod::Model, + max_passes: DEFAULT_MAX_PASSES, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, }; let handle = SearchHandle::new(); let result = suggest(&deals, ¶ms, &handle).expect("a result"); @@ -128,8 +131,10 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { &defaults, "PumpsDetection", &result.values, - 0.0, - EntryMethod::Model, + ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, ); assert!((tally.profit - 80.0).abs() < 1e-6); assert!((spent - 8_000.0).abs() < 1e-6); @@ -158,8 +163,11 @@ fn the_holdout_is_scored_but_never_fitted_on() { min_n: Some(3), seed: Some(1), train_frac: 0.75, - latency_ms: 0.0, - entry_method: EntryMethod::Model, + max_passes: DEFAULT_MAX_PASSES, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, }; let handle = SearchHandle::new(); let result = suggest(&deals, ¶ms, &handle).expect("a result"); @@ -186,8 +194,11 @@ fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { min_n: None, seed: Some(1), train_frac: 1.0, - latency_ms: 0.0, - entry_method: EntryMethod::Model, + max_passes: DEFAULT_MAX_PASSES, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, }; let handle = SearchHandle::new(); assert!( @@ -263,3 +274,44 @@ fn the_common_horizon_is_the_shortest_held_trail_and_clips_only_the_longer_tapes odd.trail_ms = -1; assert_eq!(common_horizon_ms(&[odd]), Some(0)); } + +/// A shift replays no path, so the fields that only move the path are not searched under it; +/// the corridor model searches them all. +#[test] +fn a_shift_does_not_search_the_path_only_fields() { + let base = base(); + let defaults = HashMap::new(); + let locked = HashSet::new(); + let keys = |method| { + let params = SearchParams { + base: &base, + defaults: &defaults, + kind: "MoonShot", + vary_entry: true, + vary_exit: false, + locked: &locked, + restarts: 1, + min_n: None, + seed: Some(1), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + model: ModelSettings { + entry_method: method, + ..ModelSettings::default() + }, + }; + varied(¶ms).iter().map(|f| f.key).collect::>() + }; + let shift = keys(super::super::mshot::EntryMethod::Shift); + let model = keys(super::super::mshot::EntryMethod::Model); + for path_only in [ + "MShotRaiseWait", + "MShotReplaceDelay", + "MShotUsePrice", + "FastShotAlgo", + ] { + assert!(!shift.contains(&path_only), "{path_only} {shift:?}"); + assert!(model.contains(&path_only), "{path_only} {model:?}"); + } + assert!(shift.contains(&"MShotPrice") && shift.contains(&"MShotAddDistance")); +} diff --git a/crates/moon-core/src/db/tuner/ticks/settings.rs b/crates/moon-core/src/db/tuner/ticks/settings.rs new file mode 100644 index 00000000..c3629b98 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/settings.rs @@ -0,0 +1,134 @@ +//! The model's own settings — what the replay assumes about the core and the exchange, and how +//! close a replay must come to the fact to count as reproducing it. None of them is a strategy +//! field: they are the model's, set once by the user of the tuner and read by the verdict, the +//! variant columns and the search alike, so the three can never judge a trade by different rules. +//! +//! Every default is the measured constant it replaces; the measurement stays on the constant +//! (`mshot::DEFAULT_LATENCY_MS`, `line::TICKER_PERIOD_MS`, `verify::POINT_TIME_TOLERANCE_MS`, …). + +use serde::{Deserialize, Serialize}; + +use super::line::{ + PUMP_MOVE_LAG_MS, PUMP_PEAK_LOOKBACK_MS, SERIES_TICK_MS, STEP_FLOOR_MS, TICKER_PERIOD_MS, +}; +use super::mshot::{ + DEFAULT_LATENCY_MS, EntryMethod, FAST_ALGO_WINDOW_MS, PRE_SPIKE_LOOKBACK_MS, SHIFT_WINDOW_MS, +}; +use super::verify::{ + BOOK_STOP_TIME_TOLERANCE_MS, FILL_IMPROVEMENT_TOLERANCE, POINT_TIME_TOLERANCE_MS, + STOP_PRICE_TOLERANCE, +}; + +/// The model's settings. Times in milliseconds, tolerances in per cent. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ModelSettings { + /// How a MoonShot variant's entry is replayed. + pub entry_method: EntryMethod, + /// How long a replacement — of the entry order or of the sell — takes to reach the book. + pub latency_ms: f64, + /// The window a re-placed entry order's price is read off. + pub replace_window_ms: i64, + /// How far past the fact's fill a shifted order may still be reached by the same spike. + pub shift_window_ms: i64, + /// How far before the fill the tape's "price before the spike" is read, when the archive + /// does not give the ask. + pub pre_spike_lookback_ms: i64, + /// How often the core's REST ticker brings the price a non-fast stop watches. + pub ticker_period_ms: i64, + /// The core's price-series tick, which a stop at `StopLossEMA` 0 also fires on. + pub series_tick_ms: i64, + /// The floor on a sell-line step delay of zero. + pub step_floor_ms: i64, + /// How far past `PumpMoveTimer` the pump move lands. + pub pump_move_lag_ms: i64, + /// How far before the take the pump's peak is looked for. + pub pump_peak_lookback_ms: i64, + /// Verdict: how far apart in time a modelled and an archived move may be and still be one. + pub point_time_ms: i64, + /// Verdict: how far apart in time a modelled and a factual book-watching stop may fire. + pub book_stop_time_ms: i64, + /// Verdict: how far a modelled price may sit from the fact's. + pub price_pct: f64, + /// Verdict: how far a modelled stop level may sit from the one the core fixed. + pub stop_price_pct: f64, + /// Verdict: how much better than the modelled level the fact's fill may be. + pub fill_improvement_pct: f64, +} + +impl Default for ModelSettings { + fn default() -> Self { + Self { + entry_method: EntryMethod::default(), + latency_ms: DEFAULT_LATENCY_MS, + replace_window_ms: FAST_ALGO_WINDOW_MS, + shift_window_ms: SHIFT_WINDOW_MS, + pre_spike_lookback_ms: PRE_SPIKE_LOOKBACK_MS, + ticker_period_ms: TICKER_PERIOD_MS, + series_tick_ms: SERIES_TICK_MS, + step_floor_ms: STEP_FLOOR_MS, + pump_move_lag_ms: PUMP_MOVE_LAG_MS, + pump_peak_lookback_ms: PUMP_PEAK_LOOKBACK_MS, + point_time_ms: POINT_TIME_TOLERANCE_MS, + book_stop_time_ms: BOOK_STOP_TIME_TOLERANCE_MS, + price_pct: super::PRICE_TOLERANCE * 100.0, + stop_price_pct: STOP_PRICE_TOLERANCE * 100.0, + fill_improvement_pct: FILL_IMPROVEMENT_TOLERANCE * 100.0, + } + } +} + +/// The longest time any setting may hold, milliseconds — a day. Every time is added to the +/// trade's own millisecond stamps, and a value near `i64::MAX` would wrap them silently (the +/// workspace builds without overflow checks); nothing the model reads is ever longer than the +/// tape around one trade. +pub const MAX_SETTING_MS: i64 = 24 * 60 * 60 * 1000; + +/// The widest tolerance any setting may hold, per cent. +pub const MAX_SETTING_PCT: f64 = 100.0; + +impl ModelSettings { + /// The settings with every value inside the range the model can run on: no negative time or + /// tolerance, the two clocks the walk divides by at least a millisecond, no time past + /// [`MAX_SETTING_MS`] and no tolerance past [`MAX_SETTING_PCT`]. A value that is not a number + /// takes the default. Applied wherever settings enter the model — a saved file or a typed + /// box can hold anything. + pub fn sanitized(self) -> Self { + let d = Self::default(); + let ms = |v: i64, floor: i64| v.clamp(floor, MAX_SETTING_MS); + let num = |v: f64, fallback: f64, ceiling: f64| { + if v.is_finite() { + v.clamp(0.0, ceiling) + } else { + fallback + } + }; + let pct = |v: f64, fallback: f64| num(v, fallback, MAX_SETTING_PCT); + Self { + entry_method: self.entry_method, + latency_ms: num(self.latency_ms, d.latency_ms, MAX_SETTING_MS as f64), + replace_window_ms: ms(self.replace_window_ms, 0), + shift_window_ms: ms(self.shift_window_ms, 0), + pre_spike_lookback_ms: ms(self.pre_spike_lookback_ms, 0), + ticker_period_ms: ms(self.ticker_period_ms, 1), + series_tick_ms: ms(self.series_tick_ms, 1), + step_floor_ms: ms(self.step_floor_ms, 0), + pump_move_lag_ms: ms(self.pump_move_lag_ms, 0), + pump_peak_lookback_ms: ms(self.pump_peak_lookback_ms, 0), + point_time_ms: ms(self.point_time_ms, 0), + book_stop_time_ms: ms(self.book_stop_time_ms, 0), + price_pct: pct(self.price_pct, d.price_pct), + stop_price_pct: pct(self.stop_price_pct, d.stop_price_pct), + fill_improvement_pct: pct(self.fill_improvement_pct, d.fill_improvement_pct), + } + } + + /// The replacement latency in whole milliseconds, never negative nor past + /// [`MAX_SETTING_MS`], whether or not the settings were sanitized. + pub fn latency_whole_ms(&self) -> i64 { + (self.latency_ms.max(0.0) as i64).min(MAX_SETTING_MS) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/settings/tests.rs b/crates/moon-core/src/db/tuner/ticks/settings/tests.rs new file mode 100644 index 00000000..e6a8c518 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/settings/tests.rs @@ -0,0 +1,63 @@ +use super::*; + +#[test] +fn the_defaults_are_the_measured_constants() { + let d = ModelSettings::default(); + assert_eq!(d.entry_method, EntryMethod::Model); + assert_eq!(d.latency_ms, DEFAULT_LATENCY_MS); + assert_eq!(d.ticker_period_ms, TICKER_PERIOD_MS); + assert_eq!(d.point_time_ms, POINT_TIME_TOLERANCE_MS); + // The verdict compared `d.abs() <= PRICE_TOLERANCE * 100.0`; the setting holds that product. + assert_eq!(d.price_pct, super::super::PRICE_TOLERANCE * 100.0); + assert_eq!(d.stop_price_pct, STOP_PRICE_TOLERANCE * 100.0); +} + +#[test] +fn sanitizing_keeps_the_clocks_off_zero_and_the_rest_off_negative() { + let s = ModelSettings { + ticker_period_ms: 0, + series_tick_ms: -5, + latency_ms: f64::NAN, + price_pct: -1.0, + shift_window_ms: -10, + ..ModelSettings::default() + } + .sanitized(); + assert_eq!(s.ticker_period_ms, 1); + assert_eq!(s.series_tick_ms, 1); + assert_eq!(s.latency_ms, DEFAULT_LATENCY_MS); + assert_eq!(s.price_pct, 0.0); + assert_eq!(s.shift_window_ms, 0); +} + +#[test] +fn a_file_missing_fields_reads_them_at_default() { + let s: ModelSettings = + serde_json::from_str(r#"{"latency_ms": 250.0, "entry_method": "Shift"}"#).unwrap(); + assert_eq!(s.latency_ms, 250.0); + assert_eq!(s.entry_method, EntryMethod::Shift); + assert_eq!(s.ticker_period_ms, TICKER_PERIOD_MS); +} + +/// A finite value too large to add to a timestamp is clamped, not taken: the walk adds every +/// time to the trade's own stamps. +#[test] +fn sanitizing_caps_what_would_overflow_a_timestamp() { + let s = ModelSettings { + latency_ms: 1e30, + ticker_period_ms: i64::MAX, + pump_peak_lookback_ms: i64::MAX, + price_pct: 1e9, + ..ModelSettings::default() + } + .sanitized(); + assert_eq!(s.latency_ms, MAX_SETTING_MS as f64); + assert_eq!(s.ticker_period_ms, MAX_SETTING_MS); + assert_eq!(s.pump_peak_lookback_ms, MAX_SETTING_MS); + assert_eq!(s.price_pct, MAX_SETTING_PCT); + let raw = ModelSettings { + latency_ms: 1e30, + ..ModelSettings::default() + }; + assert_eq!(raw.latency_whole_ms(), MAX_SETTING_MS); +} diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index e3a563cd..64602d86 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use super::exit::pre_spike_price; use super::exit::stop_pct as moon_core_stop_pct; -use super::mshot::{DEFAULT_LATENCY_MS, Modifiers, PRE_SPIKE_LOOKBACK_MS}; +use super::mshot::{Modifiers, PRE_SPIKE_LOOKBACK_MS}; use super::params::{StrategyValues, exit_params, mshot_params, param_keys, params_for}; use super::verify::share; use super::*; @@ -235,7 +235,10 @@ fn approaching_inside_price_min_moves_the_order_after_the_latency() { #[test] fn without_latency_the_corridor_is_never_reached_by_a_step_down() { let params = MshotParams { - latency_ms: 0.0, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, ..mshot() }; let ticks = tape(&[(0, 100.0), (1_000, 99.3), (1_000, 99.0)]); @@ -511,9 +514,9 @@ fn the_sell_family_reads_the_market_as_a_magnitude_and_the_corridor_with_its_sig values: &values, defaults: &defaults, }; - let sell = exit_params(&sv).sell_mods; + let sell = exit_params(&sv, ModelSettings::default()).sell_mods; assert!((sell.near_addition(&d) - 0.5).abs() < 1e-9); - let corridor = mshot_params(&sv, DEFAULT_LATENCY_MS).modifiers; + let corridor = mshot_params(&sv, ModelSettings::default()).modifiers; assert!((corridor.near_addition(&d) - -0.2).abs() < 1e-9); assert!(param_keys().iter().any(|k| k == "AddMarket24Delta")); } @@ -601,7 +604,10 @@ fn shifted_variant(price_pct: f64) -> (Deal, MshotParams) { }; let variant = MshotParams { price_pct, - method: EntryMethod::Shift, + model: ModelSettings { + entry_method: EntryMethod::Shift, + ..ModelSettings::default() + }, ..mshot() }; (deal, variant) @@ -647,7 +653,10 @@ fn a_short_shift_mirrors() { fn the_shift_keeps_the_fact_and_needs_it() { let (d, _) = shifted_variant(1.0); let own_by_shift = EntryParams::MoonShot(MshotParams { - method: EntryMethod::Shift, + model: ModelSettings { + entry_method: EntryMethod::Shift, + ..ModelSettings::default() + }, ..mshot() }); let spike = tape(&[(9_000, 100.0), (10_000, 99.0), (10_200, 98.4)]); @@ -661,7 +670,10 @@ fn the_shift_keeps_the_fact_and_needs_it() { &bare, &spike, &MshotParams { - method: EntryMethod::Model, + model: ModelSettings { + entry_method: EntryMethod::Model, + ..ModelSettings::default() + }, ..deeper.clone() } ), @@ -677,7 +689,10 @@ fn the_corridor_reads_the_last_print_and_re_places_off_the_windows_low() { fast_algo: true, raise_wait_s: 30.0, replace_delay_s: 0.08, - latency_ms: 0.0, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, ..mshot() }; // Level 99 off 100. A dip to 99.4 at t=1000 (0.40 % < 0.5 %: an approach) and back to 99.6 @@ -728,7 +743,10 @@ fn the_corridor_reads_the_last_print_and_re_places_off_the_windows_low() { #[test] fn no_re_place_while_the_last_is_in_flight() { let params = MshotParams { - latency_ms: 300.0, + model: ModelSettings { + latency_ms: 300.0, + ..ModelSettings::default() + }, ..mshot() }; // Level 99 off 100, on the book at once (placed off the first print). At t=1000 the price @@ -829,8 +847,14 @@ fn sell_at_last_price_lifts_the_take_to_the_pre_spike_price_less_the_adjustment( fn the_pre_spike_price_is_the_last_print_at_least_four_seconds_back() { let ticks = tape(&[(0, 100.0), (900, 100.5), (1_000, 101.0), (4_000, 95.0)]); let at = 5_000; - assert_eq!(pre_spike_price(&ticks, at), Some(101.0)); - assert_eq!(pre_spike_price(&ticks, PRE_SPIKE_LOOKBACK_MS - 1), None); + assert_eq!( + pre_spike_price(&ticks, at, PRE_SPIKE_LOOKBACK_MS), + Some(101.0) + ); + assert_eq!( + pre_spike_price(&ticks, PRE_SPIKE_LOOKBACK_MS - 1, PRE_SPIKE_LOOKBACK_MS), + None + ); } #[test] @@ -1255,7 +1279,7 @@ fn mshot_params_read_the_strategy_then_the_schema_then_the_model_default() { values: &v, defaults: &defaults, }, - DEFAULT_LATENCY_MS, + ModelSettings::default(), ); assert!((p.price_pct - 1.4).abs() < 1e-9); assert!( @@ -1283,10 +1307,13 @@ fn exit_params_read_the_sell_fields() { ("MShotSellPriceAdjust", "1"), ]); let defaults = HashMap::new(); - let p = exit_params(&StrategyValues { - values: &v, - defaults: &defaults, - }); + let p = exit_params( + &StrategyValues { + values: &v, + defaults: &defaults, + }, + ModelSettings::default(), + ); assert!((p.sell_price_pct - 0.8).abs() < 1e-9); assert!(!p.sell_at_last_price); assert!((p.sell_price_adjust_pct - 1.0).abs() < 1e-9); @@ -1299,10 +1326,13 @@ fn exit_params_read_the_stop_switch_and_trigger() { let defaults = HashMap::new(); let read = |pairs: &[(&str, &str)]| { let v = values(pairs); - exit_params(&StrategyValues { - values: &v, - defaults: &defaults, - }) + exit_params( + &StrategyValues { + values: &v, + defaults: &defaults, + }, + ModelSettings::default(), + ) }; let off = read(&[("UseStopLoss", "NO"), ("StopLoss", "-2")]); assert_eq!(off.stop_loss_pct, 0.0, "a switched-off stop arms nothing"); diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index eb7bccb2..38074cdc 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -150,7 +150,7 @@ fn dump_deal( "replace_delay_s": p.replace_delay_s, "minus_satoshi": p.minus_satoshi, "fast_algo": p.fast_algo, - "latency_ms": p.latency_ms, + "latency_ms": p.model.latency_ms, }) } _ => serde_json::Value::Null, @@ -241,10 +241,13 @@ fn core_step_lags( else { continue; }; - let exit = exit_params(&StrategyValues { - values: &values, - defaults, - }); + let exit = exit_params( + &StrategyValues { + values: &values, + defaults, + }, + ModelSettings::default(), + ); samples .entry(deal.core_uid) .or_default() @@ -427,7 +430,7 @@ fn partners_of( values: &values, defaults, }, - DEFAULT_LATENCY_MS, + ModelSettings::default(), ); let (near, far) = own.bounds_pct(&d.deltas); Some(Partner { @@ -716,13 +719,19 @@ fn real_data_reproduction() { _ => latency, }; let entry = if entry_model_for(&deal.kind) { - EntryParams::MoonShot(mshot_params(&sv, core_latency)) + EntryParams::MoonShot(mshot_params( + &sv, + ModelSettings { + latency_ms: core_latency, + ..ModelSettings::default() + }, + )) } else { EntryParams::Fact }; - let mut exit = exit_params(&sv); + let mut exit = exit_params(&sv, ModelSettings::default()); if std::env::var_os("MOON_TICKS_LATENCY_EXIT").is_some() { - exit.latency_ms = core_latency; + exit.model.latency_ms = core_latency; } deal.step_lag_ms = core_lags.get(&deal.core_uid).copied().unwrap_or(0.0); if let (Some(cache), Some((exchange, market))) = (klines.as_ref(), address.as_ref()) { @@ -777,7 +786,10 @@ fn real_data_reproduction() { for (shift, tally) in PRICE_SHIFTS.iter().zip(methods.iter_mut()) { let variant = |method| MshotParams { price_pct: (own.price_pct + shift).max(own.price_min_pct), - method, + model: ModelSettings { + entry_method: method, + ..own.model + }, ..own.clone() }; let (model, shifted_params) = @@ -831,7 +843,10 @@ fn real_data_reproduction() { && (p.buy_ms - deal.buy_ms).abs() <= 3_000 }) { let shift = MshotParams { - method: EntryMethod::Shift, + model: ModelSettings { + entry_method: EntryMethod::Shift, + ..p.params.model + }, ..p.params.clone() }; let predictions = [ diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 93a14657..cb9d5700 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -31,8 +31,9 @@ //! The exit is walked from the FACTUAL entry and held against two things: where the //! modelled line STOOD at the moment the core sold — against the price it sold at — and, when //! the order archive holds the trade's Exit line, every move the core made with its sell, -//! each of which the model must have made too within [`POINT_TIME_TOLERANCE_MS`] and -//! [`PRICE_TOLERANCE`]. A model that lands on the right price by a different path has not +//! each of which the model must have made too within the point and price tolerances +//! (`settings::ModelSettings` — [`POINT_TIME_TOLERANCE_MS`] and [`PRICE_TOLERANCE`] by +//! default). A model that lands on the right price by a different path has not //! reproduced the rule. Which PRINT the model would have sold on is not judged: that 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. The archive's own @@ -45,6 +46,7 @@ use super::exit::{ExitModel, stop_pct}; use super::line::LinePoint; use super::mshot::MshotParams; +use super::settings::ModelSettings; use super::{ Deal, EntryParams, Exit, ExitKind, ExitParams, Fill, PRICE_TOLERANCE, reaches, simulate, }; @@ -182,9 +184,10 @@ pub fn verify( .as_ref() .and_then(|a| a.fill) .map_or(deal.close_ms, |(t, _)| t.min(deal.close_ms)); + let model = &exit.model; let closed = match walked.exit.kind { ExitKind::Stop - if walked.exit.t_ms <= deal.close_ms + POINT_TIME_TOLERANCE_MS || fact_stopped => + if walked.exit.t_ms <= deal.close_ms + model.point_time_ms || fact_stopped => { walked.exit } @@ -195,10 +198,10 @@ pub fn verify( modelled.sort_by_key(|p| p.t_ms); // A point the model stamps up to its own latency after the fill is a move due // before it — the core's stamp is its moment, the model's the print plus latency. - let horizon = filled_at + exit.latency_ms.max(0.0) as i64; + let horizon = filled_at + model.latency_whole_ms(); let level = archive .as_ref() - .and_then(|a| level_on_archive_clock(&modelled, a, horizon)) + .and_then(|a| level_on_archive_clock(&modelled, a, horizon, model)) .or_else(|| modelled.iter().rev().find(|p| p.t_ms <= horizon).copied()); match level { Some(level) => Exit { @@ -246,20 +249,23 @@ pub fn verify( verify_stop(deal, &fact_exit, &walked.points, closed, exit_points) } else if exit_rule_matches(closed.kind, &deal.sell_reason) { let dev = deviation_pct(closed.price, deal.sell_price); - let tolerance = PRICE_TOLERANCE; + let tolerance = model.price_pct; // Within the tolerance either way, or a limit's fill on the better side of its level: // `dev` is the model against the fact, so a fact above the modelled sell (a long) or // below the modelled buy-back (a short) reads as a negative deviation of the model. let better_by = |d: f64| if deal.is_long() { -d } else { d }; let improved = |d: f64| { let better = better_by(d); - better > 0.0 && better <= FILL_IMPROVEMENT_TOLERANCE * 100.0 + better > 0.0 && better <= model.fill_improvement_pct }; let archived_fill = archive.as_ref().and_then(|a| a.fill); let archived_level = archive.as_ref().and_then(|a| a.moves.last().copied()); - let points = archive - .as_ref() - .map(|a| (matched_points(&walked.points, &a.moves), a.moves.len())); + let points = archive.as_ref().map(|a| { + ( + matched_points(&walked.points, &a.moves, model), + a.moves.len(), + ) + }); let line_ok = points.is_none_or(|(matched, total)| matched == total); let corroborated = points.is_some_and(|(matched, total)| matched == total); // A level placed THROUGH the market: the archive filed the fill as a point of its own @@ -275,14 +281,14 @@ pub fn verify( && archived_fill .zip(archived_level) .is_some_and(|(fill, level)| { - fill.0 - level.0 <= POINT_TIME_TOLERANCE_MS + fill.0 - level.0 <= model.point_time_ms && deviation_pct(closed.price, level.1) - .is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0) + .is_some_and(|d| d.abs() <= model.price_pct) }); let price_ok = dev.is_some_and(|d| { - d.abs() <= tolerance * 100.0 + d.abs() <= tolerance || (corroborated && improved(d)) - || (level_reproduced && better_by(d) >= -tolerance * 100.0) + || (level_reproduced && better_by(d) >= -tolerance) }); (Some(price_ok && line_ok), dev, points) } else { @@ -350,8 +356,8 @@ impl ArchivedExit { /// The modelled level at the fill read on the ARCHIVE's clock: when the model re-placed at /// every archived move, the level is the model's own point for the core's last move before the -/// fill — unless the model moved again, with no archived move to match, more than -/// [`POINT_TIME_TOLERANCE_MS`] before `horizon`: that is a step the core never took, and its +/// fill — unless the model moved again, with no archived move to match, more than the point +/// tolerance (`ModelSettings::point_time_ms`) before `horizon`: that is a step the core never took, and its /// level is what the model is held to. `None` — read the model's own clock instead — when a /// move went unmatched. /// @@ -371,20 +377,19 @@ impl ArchivedExit { /// modelled: The model's points, in time order. /// archive: The archived line — its moves and its fill point. /// horizon: The fill, plus the model's own latency. +/// model: The model's settings, for the tolerances. fn level_on_archive_clock<'a>( modelled: &[&'a LinePoint], archive: &ArchivedExit, horizon: i64, + model: &ModelSettings, ) -> Option<&'a LinePoint> { - let same = |m: &LinePoint, (t, p): (i64, f64)| { - (m.t_ms - t).abs() <= POINT_TIME_TOLERANCE_MS - && deviation_pct(m.price, p).is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0) - }; + let same = |m: &LinePoint, point: (i64, f64)| same_move(m, point, model); // A line the archive holds as its take alone says nothing about when the core stepped // (MUSEBOOK 2026-09-22 — "Auto Price Down", the archive one point long, sold 1.2 s after // the take); a take and a fill point is a line, the step filed as the fill. let filed = archive.moves.len() + usize::from(archive.fill.is_some()); - if filed < 2 || matched_points_of(modelled, &archive.moves) != archive.moves.len() { + if filed < 2 || matched_points_of(modelled, &archive.moves, model) != archive.moves.len() { return None; } let own_of = |mv: (i64, f64)| { @@ -408,7 +413,7 @@ fn level_on_archive_clock<'a>( .rev() .find(|m| { m.t_ms > own.t_ms - && m.t_ms <= horizon - POINT_TIME_TOLERANCE_MS + && m.t_ms <= horizon - model.point_time_ms && !archive .moves .iter() @@ -438,11 +443,11 @@ fn level_on_archive_clock<'a>( fn is_fill_point(deal: &Deal, exit: &ExitParams, last: (i64, f64), prev: (i64, f64)) -> bool { let (t, p) = last; let at_sale = - deviation_pct(p, deal.sell_price).is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0); + deviation_pct(p, deal.sell_price).is_some_and(|d| d.abs() <= exit.model.price_pct); if !at_sale { return false; } - let at_close = (t - deal.close_ms).abs() <= exit.latency_ms.max(0.0) as i64; + let at_close = (t - deal.close_ms).abs() <= exit.model.latency_whole_ms(); // Not worse than the level it was filed against: at or above a long's sell, at or below a // short's buy-back — `reaches` with the long's side reads "at or above". let fill_side = reaches(p, prev.1, !deal.is_long()); @@ -498,19 +503,19 @@ fn verify_stop( activation = Some(moves[i].0); moves.truncate(i); } - (matched_points(modelled, &moves), moves.len()) + (matched_points(modelled, &moves, &exit.model), moves.len()) }); let line_ok = points.is_none_or(|(matched, total)| matched == total); let tolerance_ms = if exit.fast_stop_loss { - POINT_TIME_TOLERANCE_MS + exit.model.point_time_ms } else { - BOOK_STOP_TIME_TOLERANCE_MS + exit.model.book_stop_time_ms }; let on_time = (closed.t_ms - activation.unwrap_or(deal.close_ms)).abs() <= tolerance_ms; match stated { Some(stated) => { let dev = deviation_pct(level, stated); - let level_ok = dev.is_some_and(|d| d.abs() <= STOP_PRICE_TOLERANCE * 100.0); + let level_ok = dev.is_some_and(|d| d.abs() <= exit.model.stop_price_pct); (Some(level_ok && on_time && line_ok), dev, points) } // No level on record — a book-watching stop whose stored reason cut it off (28 of 206 @@ -570,10 +575,11 @@ pub fn stated_stop_level(reason: &str) -> Option { /// How far a modelled entry may sit from the fact and still be the same order, per cent: the /// corridor's own width (`MShotPrice − MShotPriceMin` with the trade's modifiers, as they stood -/// at the fill), floored at [`PRICE_TOLERANCE`] — see the module doc. +/// at the fill), floored at the price tolerance (`ModelSettings::price_pct`) — see the module +/// doc. pub fn entry_tolerance_pct(params: &MshotParams, deal: &Deal) -> f64 { let (near, far) = params.bounds_pct(&deal.deltas_at(deal.buy_ms)); - (far - near).max(PRICE_TOLERANCE * 100.0) + (far - near).max(params.model.price_pct) } /// The replacements an archived line records: its first point and every point whose price @@ -594,22 +600,28 @@ pub fn archived_replacements(points: &[(i64, f64)]) -> Vec<(i64, f64)> { out } +/// Whether a modelled point is the archived move `(t, p)`: within the point tolerance in time +/// and the price tolerance in price. +fn same_move(m: &LinePoint, (t, p): (i64, f64), model: &ModelSettings) -> bool { + (m.t_ms - t).abs() <= model.point_time_ms + && deviation_pct(m.price, p).is_some_and(|d| d.abs() <= model.price_pct) +} + /// How many archived moves the modelled line re-placed at, within the tolerances. -fn matched_points(modelled: &[LinePoint], archived: &[(i64, f64)]) -> usize { +fn matched_points(modelled: &[LinePoint], archived: &[(i64, f64)], model: &ModelSettings) -> usize { let modelled: Vec<&LinePoint> = modelled.iter().collect(); - matched_points_of(&modelled, archived) + matched_points_of(&modelled, archived, model) } /// [`matched_points`] over borrowed points. -fn matched_points_of(modelled: &[&LinePoint], archived: &[(i64, f64)]) -> usize { +fn matched_points_of( + modelled: &[&LinePoint], + archived: &[(i64, f64)], + model: &ModelSettings, +) -> usize { archived .iter() - .filter(|&&(t, p)| { - modelled.iter().any(|m| { - (m.t_ms - t).abs() <= POINT_TIME_TOLERANCE_MS - && deviation_pct(m.price, p).is_some_and(|d| d.abs() <= PRICE_TOLERANCE * 100.0) - }) - }) + .filter(|&&point| modelled.iter().any(|m| same_move(m, point, model))) .count() } diff --git a/crates/moon-ui-gpui/src/analytics/mod.rs b/crates/moon-ui-gpui/src/analytics/mod.rs index 44025b26..99daa4b2 100644 --- a/crates/moon-ui-gpui/src/analytics/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/mod.rs @@ -871,6 +871,13 @@ impl AnalyticsView { let saved_tuner_train = backend.read(cx).layout.analytics_tuner_train; let saved_tuner_fields = backend.read(cx).layout.analytics_tuner_fields.clone(); let saved_tuner_compose = backend.read(cx).layout.analytics_tuner_compose; + // The "Entry/Exit" axis' settings. The model's are process-wide — every replay path + // reads them (`ticks::model_cfg`) — and the saved ones are what the last window left. + let mut ticks = tuner::TicksState::default(); + if let Some(saved) = backend.read(cx).layout.analytics_ticks.as_ref() { + ticks.restore(saved); + tuner::ticks::model_cfg::replace(saved.model); + } // Strategy-list sort is process-persistent. Unknown keys return to the same // profit-descending default used before this preference existed. let saved_strat_sort = @@ -1076,7 +1083,7 @@ impl AnalyticsView { saved_tuner_compose, ), coins: tuner::CoinsState::load(saved_coin_sort), - ticks: tuner::TicksState::default(), + ticks, coin_lists: tuner::CoinListsState::default(), time_tuner: tuner::TimeTunerState::load(), cal_from, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/kpi.rs b/crates/moon-ui-gpui/src/analytics/tuner/kpi.rs index 34d3e083..60a0a427 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/kpi.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/kpi.rs @@ -134,6 +134,22 @@ pub(super) fn kpi_matrix_card( collapsed: bool, p: MoonPalette, cx: &Context, +) -> AnyElement { + let fact = VarLabel::new(t!("analytics.tuner.fact").to_string()); + kpi_matrix_card_over(stats, scope, &fact, var_labels, collapsed, p, cx) +} + +/// [`kpi_matrix_card`] with column 0 headed `base` rather than "Fact" — for an axis whose +/// baseline is not the whole fact (the Entry/Exit axis compares its variants with the trades the +/// model reproduces). Every variant is still coloured against column 0. +pub(super) fn kpi_matrix_card_over( + stats: &LoadState>, + scope: String, + base: &VarLabel, + var_labels: &[VarLabel], + collapsed: bool, + p: MoonPalette, + cx: &Context, ) -> AnyElement { // The collapse caret is part of the title bar in EVERY state — built up front so it does // not blink out while the matrix is loading or after a read error. @@ -242,9 +258,9 @@ pub(super) fn kpi_matrix_card( .child(t!("analytics.tuner.metric").to_string()), ); for i in 0..stats.len() { - // Column 0 is always "Fact"; then the supplied labels, otherwise "v{i}". + // Column 0 is the baseline; then the supplied labels, otherwise "v{i}". let label = if i == 0 { - VarLabel::new(t!("analytics.tuner.fact").to_string()) + base.clone() } else { var_labels .get(i - 1) diff --git a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs index 261d5c6f..1bba468f 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs @@ -660,7 +660,7 @@ impl AnalyticsView { // (the lazy search input needs &mut self). let coins_card = (mode == StratMode::Coins).then(|| self.coins_card(p, window, cx)); // "Entry/Exit" likewise: its table settles the sort cache, which needs &mut self. - let ticks_card = (mode == StratMode::Ticks).then(|| self.ticks_card(p, cx)); + let ticks_card = (mode == StratMode::Ticks).then(|| self.ticks_card(p, window, cx)); // The right column's fold is one flag for every axis, and the rail carrying its caret is // built in BOTH states — collapsed, it is the only control left that can bring the column // back. The column below is then simply not built: `left` is already `.flex_1()`, so it diff --git a/crates/moon-ui-gpui/src/analytics/tuner/shell.rs b/crates/moon-ui-gpui/src/analytics/tuner/shell.rs index adb0d0a6..615a35bd 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/shell.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/shell.rs @@ -871,9 +871,10 @@ impl AnalyticsView { (TunerKind::Time, CfgInput::MinTrades) => self.time_tuner.min_trades.clone(), (TunerKind::Ticks, CfgInput::Restarts) => self.ticks.iters.clone(), (TunerKind::Ticks, CfgInput::MinTrades) => self.ticks.min_trades.clone(), - // The time row draws only the minimum-trades box, the tape axis no seed box, and - // the coin axis draws no row at all, so none has a value for the rest. - (TunerKind::Time, _) | (TunerKind::Coins, _) | (TunerKind::Ticks, _) => String::new(), + (TunerKind::Ticks, CfgInput::Seed) => self.ticks.seed.clone(), + // The time row draws only the minimum-trades box and the coin axis no row at all, + // so neither has a value for the rest. + (TunerKind::Time, _) | (TunerKind::Coins, _) => String::new(), }; let ph = placeholder.to_string(); let state = cx.new(|cx| { @@ -927,11 +928,18 @@ impl AnalyticsView { this.time_tuner.min_trades = value; this.time_tuner.invalidate_suggest(); } - (TunerKind::Ticks, CfgInput::Restarts) => this.ticks.iters = value, + (TunerKind::Ticks, CfgInput::Restarts) => { + this.ticks.iters = value; + this.persist_ticks_settings(cx); + } (TunerKind::Ticks, CfgInput::MinTrades) => { this.ticks.min_trades = value; } - (TunerKind::Time, _) | (TunerKind::Coins, _) | (TunerKind::Ticks, _) => {} + (TunerKind::Ticks, CfgInput::Seed) => { + this.ticks.seed = value; + this.persist_ticks_settings(cx); + } + (TunerKind::Time, _) | (TunerKind::Coins, _) => {} } if !matches!(ev, MoonInputEvent::Change) { cx.notify(); @@ -949,6 +957,18 @@ impl AnalyticsView { state } + /// Drop one settings box from its axis' cache, so the next frame builds it from the stored + /// value — after the value was set from outside the box. + pub(super) fn shell_forget_cfg_input(&mut self, kind: TunerKind, which: CfgInput) { + let id = cfg_input_id(kind, which); + match kind { + TunerKind::Filter => self.tuner.inputs.remove(id), + TunerKind::Time => self.time_tuner.inputs.remove(id), + TunerKind::Ticks => self.ticks.inputs.remove(id), + TunerKind::Coins => None, + }; + } + /// Copy the seed the last completed search ran with into the seed box, pinning it. fn pin_last_seed(&mut self, cx: &mut Context) { let Some(seed) = self.tuner.last_seed else { @@ -974,7 +994,7 @@ impl AnalyticsView { /// cx: GPUI context used to update the backend layout. /// pick: The layout field this setting lives in. /// value: Its normalized value, or `None` where the setting has no usable value. - fn persist_setting( + pub(super) fn persist_setting( &self, cx: &mut Context, pick: impl Fn(&mut moon_core::config::WindowLayout) -> &mut T, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs new file mode 100644 index 00000000..ab92b172 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs @@ -0,0 +1,748 @@ +//! The settings of the "Entry/Exit" axis, laid out as "By filter" lays out its own: the search +//! row keeps the restart count inline and puts the rest behind the ⚙ popover, with the live +//! status on a band above it; the model's settings sit behind their own popover in the deal +//! table's head, beside the ✓ column they decide. +//! +//! The split follows what each setting changes. A search setting changes what the search looks +//! at and how hard; the entry method changes how a VARIANT is replayed, never the ✓ of the fact +//! (the verdict replays the corridor model on the trade's own settings), so it sits with the +//! search too. A model setting — a latency, a clock, a window, a tolerance — changes the replay +//! of every trade, the fact's included, so committing one judges the whole table again +//! (`ticks_replay_again`). +//! +//! Every setting persists (`WindowLayout::analytics_ticks`) but the minimum trades, which the +//! filter axis does not keep either. + +use gpui::prelude::FluentBuilder; +use gpui::*; +use moon_ui::{ + MoonButton, MoonButtonSize, MoonButtonVariant, MoonDropdown, MoonInput, MoonInputEvent, + MoonInputState, MoonPalette, MoonPopover, MoonPopoverPlacement, MoonTooltipView, h_flex, + v_flex, +}; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +use super::super::shared::TunerKind; +use super::super::shell::{CfgInput, train_label}; +use super::model_cfg::{self, MODEL_FIELDS, ModelField, Section, field_text, parse_field}; +use super::state::{DEFAULT_GATE_PCT, SuggState}; +use crate::design; +use crate::design::moon; +use moon_core::db::tuner::ticks::{EntryMethod, ModelSettings}; + +/// Content width of the two popovers, before the font scale and the component's own padding. +const POPUP_W: f32 = 270.0; + +/// Width of a settings row's caption, font-scaled px. +const LABEL_W: f32 = 150.0; + +/// The input-box cache key of one model setting. +fn model_input_id(field: &ModelField) -> String { + format!("m:{}", field.id) +} + +/// The locale key of an entry method's name, and of its one-line account. +fn method_keys(method: EntryMethod) -> (&'static str, &'static str) { + match method { + EntryMethod::Model => ( + "analytics.ticks.method_model", + "analytics.ticks.method_model_help", + ), + EntryMethod::Shift => ( + "analytics.ticks.method_shift", + "analytics.ticks.method_shift_help", + ), + } +} + +impl AnalyticsView { + /// The search row of the axis: the status band, then restarts, the settings gear, Stop, + /// "Search" on the selected field and "Search all". + pub(in crate::analytics::tuner) fn ticks_config_row( + &mut self, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let running = matches!(self.ticks.sugg, SuggState::Running { .. }); + let (status, status_color) = match &self.ticks.sugg { + // Counted against the restarts the run was launched with, not the box's current + // text. + SuggState::Running { handle, total } => ( + t!( + "analytics.tuner.sugg_progress", + done = handle.completed(), + total = total + ) + .to_string(), + p.text_soft, + ), + SuggState::Idle => match &self.ticks.sugg_note { + Some(note) => (note.clone(), p.amber), + None => (String::new(), p.text_muted), + }, + }; + let placeholder = super::variants::DEFAULT_RESTARTS.to_string(); + let it_input = self.shell_cfg_input( + TunerKind::Ticks, + CfgInput::Restarts, + &placeholder, + window, + cx, + ); + let settings = self.ticks_search_settings(p, window, cx); + let one_tip = match self.ticks.sel_field { + Some(key) => t!("analytics.ticks.suggest_one_tip", field = key).to_string(), + None => t!("analytics.ticks.suggest_one_none").to_string(), + }; + let controls = h_flex() + .w_full() + .flex_none() + .px(design::ui_px(cx, 12.0)) + .pb(design::ui_px(cx, 6.0)) + .items_center() + .gap(design::ui_px(cx, 6.0)) + .text_size(design::t_caption(cx)) + .font_family(design::ui_font()) + .child( + div() + .flex_none() + .text_color(moon(p.text_muted)) + .child(t!("analytics.tuner.iters").to_string()), + ) + .child( + div() + .w(design::font_w_px(cx, 46.0)) + .flex_none() + .font_family(design::mono()) + .child( + MoonInput::new("tun-cfg-it-x") + .state(&it_input) + .size(design::INPUT_SIZE), + ), + ) + .child(settings) + .child(div().flex_1()) + .when(running, |el| { + el.child( + div().flex_none().child( + MoonButton::new("tun-suggest-stop-x") + .variant(MoonButtonVariant::Soft) + .label(t!("analytics.tuner.stop").to_string()) + .on_click(cx.listener(|this, _, _, cx| this.ticks_stop_suggest(cx))) + .render(), + ), + ) + }) + .child( + div() + .id("tun-suggest-one-x-box") + .flex_none() + .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(one_tip.clone())).into()) + .child( + MoonButton::new("tun-suggest-one-x") + .variant(MoonButtonVariant::Soft) + .label(t!("analytics.tuner.suggest_one").to_string()) + .disabled(running || self.ticks.sel_field.is_none()) + .on_click(cx.listener(|this, _, _, cx| this.ticks_suggest_one(cx))) + .render(), + ), + ) + .child( + div().flex_none().child( + MoonButton::new("tun-suggest-run-x") + .variant(MoonButtonVariant::Blue) + .label(t!("analytics.tuner.suggest_run").to_string()) + .disabled(running) + .on_click(cx.listener(|this, _, _, cx| this.ticks_suggest(cx))) + .render(), + ), + ); + v_flex() + .w_full() + .flex_none() + .when(!status.is_empty(), |el| { + el.child( + div() + .id("tun-suggest-status-x") + .w_full() + .min_w_0() + .px(design::ui_px(cx, 12.0)) + .pb(design::ui_px(cx, 4.0)) + .truncate() + .text_size(design::t_caption(cx)) + .font_family(design::ui_font()) + .text_color(moon(status_color)) + .tooltip(crate::panels::common::text_tooltip(status.clone())) + .child(status), + ) + }) + .child(controls) + .into_any_element() + } + + /// The search settings popover and its gear. Built only while open, as the filter's is: + /// `MoonPopover` takes its content eagerly, and the panel repaints through a whole search. + fn ticks_search_settings( + &mut self, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let open = self.ticks.sugg_cfg_open; + let content = open.then(|| self.ticks_search_settings_content(p, window, cx)); + let entity = cx.entity(); + let mut popover = MoonPopover::new("tun-cfg-popover-x") + .placement(MoonPopoverPlacement::BottomStart) + .content_width_font(POPUP_W) + .close_on_content_click(false) + // A dropdown inside paints in its own layer, and the outside-click test would shut + // the popover before the pick landed (docs-internal/FORK_BUGS.md, Popover); the ✕ and + // the gear close it. + .overlay_closable(false) + .open(open) + .on_open_change(move |open, _window, app| { + entity.update(app, |this, cx| { + this.ticks.sugg_cfg_open = open; + cx.notify(); + }); + }) + .trigger( + MoonButton::new("tun-cfg-gear-x") + .label("⚙") + .variant(MoonButtonVariant::Soft) + .tooltip(t!("analytics.tuner.cfg_title").to_string()) + .render(), + ); + if let Some(content) = content { + popover = popover.content(content); + } + popover.into_any_element() + } + + fn ticks_search_settings_content( + &mut self, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let mn_input = self.shell_cfg_input( + TunerKind::Ticks, + CfgInput::MinTrades, + &t!("analytics.tuner.auto_ph"), + window, + cx, + ); + let seed_input = self.shell_cfg_input( + TunerKind::Ticks, + CfgInput::Seed, + &t!("analytics.tuner.seed_ph"), + window, + cx, + ); + let passes_input = self.ticks_text_input( + "x-cfg-passes", + self.ticks.passes.clone(), + moon_core::db::tuner::ticks::search::DEFAULT_MAX_PASSES.to_string(), + |this, value| this.ticks.passes = value, + window, + cx, + ); + let gate_input = self.ticks_text_input( + "x-cfg-gate", + self.ticks.gate_pct.clone(), + DEFAULT_GATE_PCT.to_string(), + |this, value| this.ticks.gate_pct = value, + window, + cx, + ); + let train_pct = self.ticks.train_pct; + let tr_view = cx.entity(); + let tr_items = crate::panels::radio_items( + super::super::filter::state::TRAIN_OPTIONS.map(|n| { + ( + n, + SharedString::from(format!("tun-tr-x-{n}")), + SharedString::from(train_label(n)), + ) + }), + train_pct, + crate::panels::RadioMark::Highlight, + move |app, n| { + tr_view.update(app, |this, cx| { + this.ticks.train_pct = n; + this.persist_ticks_settings(cx); + cx.notify(); + }); + }, + ); + let tr_combo = MoonDropdown::new("tun-cfg-tr-x") + .label(train_label(train_pct)) + .trigger_caret(true) + .trigger_variant(MoonButtonVariant::Soft) + .trigger_size(MoonButtonSize::density(cx)) + .menu_width_scaled(96.0) + .items(tr_items); + let method = model_cfg::current().entry_method; + let me_view = cx.entity(); + let me_items = crate::panels::radio_items( + [EntryMethod::Model, EntryMethod::Shift].map(|m| { + ( + m, + SharedString::from(format!("tun-me-x-{m:?}")), + SharedString::from(t!(method_keys(m).0).to_string()), + ) + }), + method, + crate::panels::RadioMark::Highlight, + move |app, m| { + me_view.update(app, |this, cx| this.ticks_set_entry_method(m, cx)); + }, + ); + let me_combo = MoonDropdown::new("tun-cfg-me-x") + .label(t!(method_keys(method).0).to_string()) + .trigger_caret(true) + .trigger_variant(MoonButtonVariant::Soft) + .trigger_size(MoonButtonSize::density(cx)) + .menu_width_scaled(140.0) + .items(me_items); + let box_of = |id: &'static str, state: &Entity, w: f32| { + div() + .w(design::font_w_px(cx, w)) + .flex_none() + .font_family(design::mono()) + .child( + MoonInput::new(SharedString::from(id)) + .state(state) + .size(design::INPUT_SIZE), + ) + .into_any_element() + }; + let mut content = popup_frame("tun-cfg-popup-x", window, cx) + .child(popup_head( + t!("analytics.tuner.cfg_title").to_string(), + "tun-cfg-close-x", + |this| this.ticks.sugg_cfg_open = false, + p, + cx, + )) + .child(popup_section( + t!("analytics.tuner.cfg_search_section").to_string(), + p, + cx, + )) + .child(popup_row( + t!("analytics.tuner.min_trades").to_string(), + None, + box_of("tun-cfg-mn-x", &mn_input, 76.0), + p, + cx, + )) + .child(popup_row( + t!("analytics.ticks.cfg_passes").to_string(), + Some(t!("analytics.ticks.cfg_passes_tip").to_string()), + box_of("tun-cfg-passes-x", &passes_input, 76.0), + p, + cx, + )) + .child(popup_row( + t!("analytics.ticks.cfg_gate").to_string(), + Some(t!("analytics.ticks.cfg_gate_tip").to_string()), + box_of("tun-cfg-gate-x", &gate_input, 76.0), + p, + cx, + )) + .child(popup_section( + t!("analytics.ticks.cfg_entry_section").to_string(), + p, + cx, + )) + .child(popup_row( + t!("analytics.ticks.cfg_method").to_string(), + None, + div().flex_none().child(me_combo).into_any_element(), + p, + cx, + )) + .child( + div() + .w_full() + .text_color(moon(p.text_muted)) + .child(t!(method_keys(method).1).to_string()), + ) + .child(popup_section( + t!("analytics.tuner.cfg_validation_section").to_string(), + p, + cx, + )) + .child(popup_row( + t!("analytics.tuner.train_share").to_string(), + None, + div().flex_none().child(tr_combo).into_any_element(), + p, + cx, + )) + .child(popup_section( + t!("analytics.tuner.cfg_repeat_section").to_string(), + p, + cx, + )) + .child(popup_row( + t!("analytics.tuner.seed").to_string(), + None, + box_of("tun-cfg-seed-x", &seed_input, 126.0), + p, + cx, + )); + // The seed the last search ran with, so an interesting answer can be repeated. + if let Some(last) = self.ticks.last_seed { + content = content.child(popup_row( + t!("analytics.tuner.seed_last").to_string(), + None, + h_flex() + .items_center() + .gap(design::ui_px(cx, 6.0)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .font_family(design::mono()) + .text_color(moon(p.text_soft)) + .child(last.to_string()), + ) + .child( + MoonButton::new("tun-cfg-seed-pin-x") + .label(t!("analytics.tuner.seed_pin").to_string()) + .variant(MoonButtonVariant::Soft) + .on_click(cx.listener(move |this, _, _, cx| { + this.ticks.seed = last.to_string(); + this.shell_forget_cfg_input(TunerKind::Ticks, CfgInput::Seed); + this.persist_ticks_settings(cx); + cx.notify(); + })) + .render(), + ) + .into_any_element(), + p, + cx, + )); + } + content.into_any_element() + } + + /// The model settings popover and its button, for the deal table's head. Built only while + /// open. + pub(in crate::analytics::tuner) fn ticks_model_settings( + &mut self, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let open = self.ticks.model_cfg_open; + let content = open.then(|| self.ticks_model_settings_content(p, window, cx)); + let entity = cx.entity(); + let mut popover = MoonPopover::new("an-ticks-model-popover") + .placement(MoonPopoverPlacement::BottomEnd) + .content_width_font(POPUP_W) + .close_on_content_click(false) + .overlay_closable(false) + .open(open) + .on_open_change(move |open, _window, app| { + entity.update(app, |this, cx| { + this.ticks.model_cfg_open = open; + cx.notify(); + }); + }) + .trigger( + MoonButton::new("an-ticks-model-btn") + .label(t!("analytics.ticks.model_btn").to_string()) + .variant(MoonButtonVariant::Soft) + .tooltip(t!("analytics.ticks.model_title").to_string()) + .render(), + ); + if let Some(content) = content { + popover = popover.content(content); + } + popover.into_any_element() + } + + fn ticks_model_settings_content( + &mut self, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let mut content = popup_frame("an-ticks-model-popup", window, cx) + .child(popup_head( + t!("analytics.ticks.model_title").to_string(), + "an-ticks-model-close", + |this| this.ticks.model_cfg_open = false, + p, + cx, + )) + // What the model does not know, whatever its settings. + .child( + div() + .w_full() + .text_color(moon(p.text_muted)) + .child(t!("analytics.ticks.assumptions").to_string()), + ); + let mut section: Option
= None; + for field in MODEL_FIELDS { + if section != Some(field.section) { + section = Some(field.section); + content = content.child(popup_section( + t!(field.section.title_key()).to_string(), + p, + cx, + )); + } + let input = self.ticks_model_input(field, window, cx); + content = content.child(popup_row( + t!(field.label).to_string(), + Some(t!(field.tip).to_string()), + div() + .w(design::font_w_px(cx, 76.0)) + .flex_none() + .font_family(design::mono()) + .child( + MoonInput::new(SharedString::from(format!("an-ticks-m-{}", field.id))) + .state(&input) + .size(design::INPUT_SIZE), + ) + .into_any_element(), + p, + cx, + )); + } + content + .child( + h_flex().w_full().justify_end().child( + MoonButton::new("an-ticks-model-reset") + .label(t!("analytics.ticks.model_reset").to_string()) + .variant(MoonButtonVariant::Soft) + .disabled( + model_cfg::current() + == ModelSettings { + entry_method: model_cfg::current().entry_method, + ..ModelSettings::default() + }, + ) + .on_click(cx.listener(|this, _, _, cx| this.ticks_reset_model(cx))) + .render(), + ), + ) + .into_any_element() + } + + /// A plain text box of a search setting, cached in the axis' inputs: every change is taken + /// at once, as the filter's settings are, and persisted. + fn ticks_text_input( + &mut self, + id: &'static str, + value: String, + placeholder: String, + apply: fn(&mut AnalyticsView, String), + window: &mut Window, + cx: &mut Context, + ) -> Entity { + if let Some(state) = self.ticks.inputs.get(id) { + return state.clone(); + } + let state = cx.new(|cx| { + MoonInputState::new(window, cx) + .default_value(value) + .placeholder(placeholder) + }); + cx.subscribe_in( + &state, + window, + move |this, state, ev: &MoonInputEvent, _window, cx| { + if matches!( + ev, + MoonInputEvent::Change + | MoonInputEvent::Blur + | MoonInputEvent::PressEnter { .. } + ) { + apply(this, state.read(cx).value().to_string()); + this.persist_ticks_settings(cx); + if !matches!(ev, MoonInputEvent::Change) { + cx.notify(); + } + } + }, + ) + .detach(); + self.ticks.inputs.insert(id.to_string(), state.clone()); + state + } + + /// The box of one model setting. Taken on Enter or when the box loses focus, never per + /// keystroke: each commit judges the whole table again. A value the model cannot take puts + /// the box back to the setting in force. + fn ticks_model_input( + &mut self, + field: &'static ModelField, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let id = model_input_id(field); + if let Some(state) = self.ticks.inputs.get(&id) { + return state.clone(); + } + let value = field_text(field, &model_cfg::current()); + let state = cx.new(|cx| MoonInputState::new(window, cx).default_value(value)); + cx.subscribe_in( + &state, + window, + move |this, state, ev: &MoonInputEvent, _window, cx| { + if !matches!(ev, MoonInputEvent::Blur | MoonInputEvent::PressEnter { .. }) { + return; + } + let typed = state.read(cx).value().to_string(); + let mut settings = model_cfg::current(); + if let Some(value) = parse_field(&typed) { + (field.set)(&mut settings, value); + this.ticks_set_model(settings, cx); + } + // The box shows the setting in force: the sanitized value, or the old one. + if typed != field_text(field, &model_cfg::current()) { + this.ticks.inputs.remove(&model_input_id(field)); + } + cx.notify(); + }, + ) + .detach(); + self.ticks.inputs.insert(id, state.clone()); + state + } + + /// Put model settings in force: persisted, and every row judged again under them. + fn ticks_set_model(&mut self, settings: ModelSettings, cx: &mut Context) { + if model_cfg::replace(settings) { + self.persist_ticks_settings(cx); + self.ticks_replay_again(cx); + } + } + + /// Every model setting back to its measured default; the entry method, a search setting, + /// stays. + fn ticks_reset_model(&mut self, cx: &mut Context) { + let settings = ModelSettings { + entry_method: model_cfg::current().entry_method, + ..ModelSettings::default() + }; + self.ticks.inputs.retain(|id, _| !id.starts_with("m:")); + self.ticks_set_model(settings, cx); + cx.notify(); + } + + /// Pick how a variant's entry is replayed. The fact's ✓ does not depend on it, so the + /// table stays as it is; the variant columns are scored again, and the grid greys out the + /// fields the method does not read. + fn ticks_set_entry_method(&mut self, method: EntryMethod, cx: &mut Context) { + let settings = ModelSettings { + entry_method: method, + ..model_cfg::current() + }; + if model_cfg::replace(settings) { + self.persist_ticks_settings(cx); + self.arm_ticks_variants(cx); + } + cx.notify(); + } + + /// Write the axis' settings into the saved layout. + pub(in crate::analytics::tuner) fn persist_ticks_settings(&self, cx: &mut Context) { + let value = Some(self.ticks.saved()); + self.persist_setting(cx, |l| &mut l.analytics_ticks, value); + } +} + +/// The popovers' scroll-bounded column. No padding, background, border or corners: the popover +/// supplies them (see the filter's settings popover). +fn popup_frame(id: &'static str, window: &Window, cx: &Context) -> Stateful
{ + let max_h = px( + (f32::from(window.viewport_size().height) - f32::from(design::ui_px(cx, 24.0))) + .max(f32::from(design::ui_px(cx, 180.0))), + ); + v_flex() + .id(id) + .w_full() + .max_h(max_h) + .overflow_y_scroll() + .gap(design::ui_px(cx, 6.0)) + .text_size(design::t_caption(cx)) + .font_family(design::ui_font()) +} + +/// A popover's title line with its ✕. +fn popup_head( + title: String, + close_id: &'static str, + close: fn(&mut AnalyticsView), + p: MoonPalette, + cx: &Context, +) -> AnyElement { + h_flex() + .w_full() + .items_center() + .child( + div() + .flex_1() + .font_weight(FontWeight::SEMIBOLD) + .text_color(moon(p.text)) + .child(title), + ) + .child( + MoonButton::new(close_id) + .label("✕") + .variant(MoonButtonVariant::Ghost) + .on_click(cx.listener(move |this, _, _, cx| { + close(this); + cx.notify(); + })) + .render(), + ) + .into_any_element() +} + +/// A section heading inside a popover. +fn popup_section(title: String, p: MoonPalette, cx: &Context) -> AnyElement { + div() + .w_full() + .pt(design::ui_px(cx, 5.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(moon(p.text_soft)) + .child(title) + .into_any_element() +} + +/// One setting: its caption (with a tooltip when it needs explaining) and its control. +fn popup_row( + caption: String, + tip: Option, + body: AnyElement, + p: MoonPalette, + cx: &Context, +) -> AnyElement { + let label = div() + .id(SharedString::from(format!("tun-cfg-lbl-{caption}"))) + .w(design::font_w_px(cx, LABEL_W)) + .flex_none() + .truncate() + .text_color(moon(p.text_muted)) + .child(caption) + .when_some(tip, |el, tip| { + el.tooltip(crate::panels::common::text_tooltip(tip)) + }); + h_flex() + .w_full() + .items_center() + .gap(design::ui_px(cx, 6.0)) + .child(label) + .child(body) + .into_any_element() +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index 3619c2ad..11a23c99 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -16,7 +16,7 @@ use std::time::Duration; use gpui::*; use super::super::super::AnalyticsView; -use super::state::{RowAddress, TapeStatus}; +use super::state::{RowAddress, RowEdit, TapeStatus}; use crate::Backend; use moon_core::db::tuner::ticks::{Deal, model_window}; use moon_core::market::MarketDataSource; @@ -228,13 +228,12 @@ impl AnalyticsView { let in_flight = job::progress().in_flight; job::stop(); autoload::cancel(); - for uid in in_flight.into_iter().flat_map(|(uids, _)| uids) { - self.ticks.update_row(uid, |row| { - if row.tape == TapeStatus::Fetching { - row.tape = TapeStatus::Missing; - } - }); - } + self.ticks.edit_rows( + in_flight + .into_iter() + .flat_map(|(uids, _)| uids) + .map(|uid| (uid, RowEdit::UnmarkFetching)), + ); cx.notify(); } @@ -282,9 +281,7 @@ impl AnalyticsView { // hear. let applied = cx.update(|cx| { this.update(cx, |this, cx| { - for event in events { - this.apply_fetch_event(event, cx); - } + this.apply_fetch_events(events, cx); cx.notify(); }) }); @@ -302,42 +299,38 @@ impl AnalyticsView { })); } - /// Fold one job event into the table. - fn apply_fetch_event(&mut self, event: job::JobEvent, cx: &mut Context) { - match event { - // A start still queued when the batch was stopped marks nothing: no answer would - // follow to unmark it. - job::JobEvent::Started(uid) if job::progress().active => { - self.ticks.update_row(uid, |row| { - if row.tape == TapeStatus::Missing { - row.tape = TapeStatus::Fetching; - } - }); - } - job::JobEvent::Started(_) => {} - job::JobEvent::Row(answer) => { - let uid = answer.deal.report_uid; - self.ticks.update_row(uid, |slot| slot.take_replay(*answer)); - // A row joined the replayable set: the variant columns are due a rescore. - self.arm_ticks_variants(cx); - } - job::JobEvent::Progress => {} + /// Fold one hop of job events into the table, in order, with one recount for the lot. + fn apply_fetch_events(&mut self, events: Vec, cx: &mut Context) { + // A start still queued when the batch was stopped marks nothing: no answer would follow + // to unmark it. + let active = job::progress().active; + let mut answered = false; + let edits: Vec<(i64, RowEdit)> = events + .into_iter() + .filter_map(|event| match event { + job::JobEvent::Started(uid) if active => Some((uid, RowEdit::MarkFetching)), + job::JobEvent::Row(answer) => { + answered = true; + Some((answer.deal.report_uid, RowEdit::Replay(answer))) + } + job::JobEvent::Started(_) | job::JobEvent::Progress => None, + }) + .collect(); + self.ticks.edit_rows(edits); + // Rows joined the replayable set: the variant columns are due a rescore — once. + if answered { + self.arm_ticks_variants(cx); } - cx.notify(); } /// Mark the rows the job is out for, after the table was rebuilt. pub(in crate::analytics::tuner) fn mark_fetch_in_flight(&mut self) { - for uid in job::progress() - .in_flight - .into_iter() - .flat_map(|(uids, _)| uids) - { - self.ticks.update_row(uid, |row| { - if row.tape == TapeStatus::Missing { - row.tape = TapeStatus::Fetching; - } - }); - } + self.ticks.edit_rows( + job::progress() + .in_flight + .into_iter() + .flat_map(|(uids, _)| uids) + .map(|uid| (uid, RowEdit::MarkFetching)), + ); } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs index 1bb5b300..6c97d2bc 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs @@ -13,9 +13,9 @@ //! 2. resolve each through the live source, keep the ones the venue still serves by the //! worker's own retention rule (`inside_retention`: the exit inside the route's retention; a //! venue with no route is skipped — nothing to ask), and hand them to the fetch job -//! ([`super::job::enqueue`]) — the job asks the -//! worker, and the worker serves what the tiles and `trades.sqlite` already hold without a -//! request, so a trade the capture DID file costs nothing here; +//! ([`super::job::enqueue`]) — minus the ones whose tape `trades.sqlite` already holds +//! ([`drop_held`], answered off the span table's bounds, one read per market): those would +//! come back from the job served off the disk, one at a time, every launch; //! 3. a trade whose core is not connected yet, or whose catalog is not in, is kept and tried //! again every [`RETRY`] for up to [`MAX_ATTEMPTS`]: the cores come up one by one after the //! terminal, and the catalog a little after each core. @@ -34,13 +34,17 @@ use std::time::{Duration, Instant}; use gpui::App; -use super::job; +use std::collections::HashMap; + +use super::job::{self, QueuedRow}; use super::{FetchResolver, strategy_field_defaults}; use crate::Backend; -use moon_core::db::tuner::ticks::{Deal, model_window}; +use moon_core::db::tuner::ticks::{Deal, model_window, required_spans}; use moon_core::market::trade_replay::venue_caps::trade_route; use moon_core::market::trade_replay::worker::inside_retention; -use moon_core::market::trade_replay::{long_position_ms, margin_ms}; +use moon_core::market::trade_replay::{ + Coverage, ReplayWindow, long_position_ms, margin_ms, trade_cache, +}; /// How far back the autoload looks, whatever the venue documents: the longest retention a /// route names is 90 days, and a month of rows is already thousands of walks. @@ -257,6 +261,19 @@ fn run_pass( } rows.push(row); } + let (mut rows, held) = drop_held( + rows, + |row: &QueuedRow| { + ( + row.address.exchange_key.clone(), + row.address.market.clone(), + row.window, + ) + }, + |exchange, market, from_ms, to_ms| { + trade_cache::handle()?.held_spans(exchange, market, from_ms, to_ms) + }, + ); // Newest-first is the job's queue order: it pops from the end, oldest first. rows.sort_by_key(|row| std::cmp::Reverse(row.deal.close_ms)); let offered = rows.len(); @@ -280,13 +297,63 @@ fn run_pass( }; log::info!( target: moon_core::diagnostics::TICKS_AXIS_TARGET, - "[x] ticks autoload pass {attempt}: {total} deal(s) considered, {queued} queued ({} already in the batch), {no_route} with no route, {out_of_retention} past the venue's retention, {degenerate} with no window, {} unresolved (core not connected or catalog without the coin)", + "[x] ticks autoload pass {attempt}: {total} deal(s) considered, {queued} queued ({} already in the batch), {held} already held on disk, {no_route} with no route, {out_of_retention} past the venue's retention, {degenerate} with no window, {} unresolved (core not connected or catalog without the coin)", offered - queued, unresolved.len() ); unresolved } +/// Drop the rows whose tape the disk already holds — every stretch the model needs of the +/// window (`required_spans`, the rule the axis marks a row covered by) inside the spans +/// `trades.sqlite` has filed for the market. One bounds read per market, over the stretch its +/// rows span; a market whose read did not happen keeps every row — the job then asks, as it +/// always did. +/// +/// Args: +/// rows: The candidates. +/// place: A row's `(exchange key, market, window)`. +/// held_spans: `(exchange, market, from_ms, to_ms)` → the stored spans' bounds, `None` when +/// the read did not happen. +/// +/// Returns: +/// The rows still worth the job, and how many were dropped as held. +fn drop_held( + rows: Vec, + place: impl Fn(&T) -> (String, String, ReplayWindow), + held_spans: impl Fn(&str, &str, i64, i64) -> Option>, +) -> (Vec, usize) { + let mut by_market: HashMap<(String, String), Vec<(T, Coverage)>> = HashMap::new(); + for row in rows { + let (exchange, market, window) = place(&row); + by_market + .entry((exchange, market)) + .or_default() + .push((row, required_spans(&window))); + } + let mut kept = Vec::new(); + let mut held = 0usize; + for ((exchange, market), group) in by_market { + let bounds = group + .iter() + .filter_map(|(_, need)| need.hull()) + .reduce(|(a_from, a_to), (b_from, b_to)| (a_from.min(b_from), a_to.max(b_to))); + let stored = bounds + .and_then(|(from_ms, to_ms)| held_spans(&exchange, &market, from_ms, to_ms)) + .map(Coverage::from_spans); + for (row, need) in group { + match &stored { + Some(stored) if !need.is_empty() && stored.covers(&need) => held += 1, + _ => kept.push(row), + } + } + } + (kept, held) +} + +#[cfg(test)] +mod tests; + /// The candidates: every closed trade with millisecond stamps of the last [`HORIZON_MS`], on /// every core, under the axis' own filters. fn read_recent( diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload/tests.rs new file mode 100644 index 00000000..8b78b301 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload/tests.rs @@ -0,0 +1,45 @@ +use super::*; + +/// A trade `(exchange, market, open_ms, close_ms)` and its window. +fn trade(market: &str, open_ms: i64, close_ms: i64) -> (String, String, ReplayWindow) { + let window = moon_core::market::trade_replay::replay_window_ms(open_ms, close_ms, 30_000) + .expect("a window"); + ("x".to_string(), market.to_string(), window) +} + +/// A row whose every needed stretch the disk holds is dropped; one with a hole, one on a market +/// the disk has nothing of, and every row of a market whose read did not happen are kept. +#[test] +fn only_the_rows_the_disk_fully_holds_are_dropped() { + let base = 1_790_000_000_000; + let held_trade = trade("M", base, base + 10_000); + let need = required_spans(&held_trade.2).hull().expect("a need"); + let hole_trade = trade("M", base + 3_600_000, base + 3_610_000); + let other_market = trade("N", base, base + 10_000); + let unread_market = trade("U", base, base + 10_000); + let rows = vec![held_trade, hole_trade, other_market, unread_market]; + let (kept, held) = drop_held( + rows, + |row| row.clone(), + |_, market, _, _| match market { + // Two abutting spans over the first trade's need: one stretch. + "M" => Some(vec![(need.0 - 5, need.0 + 100), (need.0 + 101, need.1 + 5)]), + "N" => Some(Vec::new()), + _ => None, + }, + ); + assert_eq!(held, 1); + let mut markets: Vec<(String, i64)> = kept + .iter() + .map(|(_, m, w)| (m.clone(), w.open_ms)) + .collect(); + markets.sort(); + assert_eq!( + markets, + vec![ + ("M".to_string(), base + 3_600_000), + ("N".to_string(), base), + ("U".to_string(), base), + ] + ); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs index e1d5be76..4681c6a0 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -732,6 +732,7 @@ fn serve_cluster( replay_row( &mut answer, defaults, + super::super::model_cfg::current(), lines, row.window.long_position_ms, row.replay_address.cache.as_ref(), diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index 894ef23e..526faf41 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -1,36 +1,34 @@ -//! The parameter grid of the "Entry/Exit" axis: the two groups of `TICK_PARAMS`, each behind -//! its own caret and its own "vary" switch, with the "now" value of the selected strategies, -//! the variant columns В1/В2 as input boxes, and a "fix" tick per field that holds it out of -//! the search. +//! The parameter grid of the "Entry/Exit" axis, laid out as the "By filter" grid is: a tick per +//! field that admits it to the search (the header's tick admits them all), the field's name — +//! a click selects it for "Search" on one field — the value the selected strategies hold, which +//! a click sends to В1, and the two variant columns with the copy arrows and the clear crosses. //! -//! A group's "vary" switch is locked while the model does not reproduce enough of the fact -//! for that group (`SHARE_GATE`): searching over a model that cannot replay what happened -//! optimizes noise, and the switch says so in its tooltip. The Entry group is shown only when -//! every kind in the scope has an entry model; otherwise it folds to one line saying whose -//! entry is taken from the fact. +//! The rows come in two groups, Entry and Exit. A group the model does not reproduce well enough +//! (the share gate of the search settings) is not searched, and its heading says so; the Entry +//! group folds to one line where a kind in the scope has no entry model. A field the entry +//! method does not read — the path-only fields under the shift — is greyed out: varying it would +//! move no column. +use gpui::prelude::FluentBuilder; use gpui::*; use moon_ui::{ - MoonButton, MoonButtonVariant, MoonCheckbox, MoonInput, MoonInputEvent, MoonInputState, - MoonPalette, MoonTooltipView, h_flex, v_flex, + MoonCheckbox, MoonInput, MoonInputEvent, MoonInputState, MoonPalette, h_flex, v_flex, }; use rust_i18n::t; use super::super::super::AnalyticsView; -use super::super::shared::{N_VAR, TunerKind, collapse_caret}; -use super::state::{NowValue, SHARE_GATE, TicksData}; +use super::super::shared::{N_VAR, TunerKind, glyph_btn}; +use super::state::{NowValue, TicksData}; use crate::design; use crate::design::{moon, moon_alpha}; use moon_core::db::tuner::ticks::params::{ParamGroup, TickParam, params_for}; -/// Width of the "now" and variant cells, font-scaled px. -const CELL_W: f32 = 72.0; -/// Width of the "fix" tick cell. -const FIX_W: f32 = 28.0; +/// Width of the strategy and variant cells, font-scaled px. +const CELL_W: f32 = 60.0; impl AnalyticsView { - /// The grid panel: the shared toolbar (title, Copy, Save), the search row, then the - /// assumptions line and the two groups, scrolling. + /// The grid panel: the shared toolbar (title, Copy, Save), the search row, then the two + /// groups, scrolling. pub(in crate::analytics::tuner) fn ticks_grid( &mut self, p: MoonPalette, @@ -44,64 +42,21 @@ impl AnalyticsView { ); let cfg_row = self.shell_config_row(TunerKind::Ticks, p, window, cx); let data = self.ticks.data.data().cloned(); - let mut body = v_flex().w_full().flex_none(); - // What the model does not know, in one line — the spec's §4.4, kept where the user - // reads the numbers the assumptions shape. - body = body.child( - div() - .w_full() - .px(design::ui_px(cx, 12.0)) - .pb(design::ui_px(cx, 6.0)) - .font_family(design::ui_font()) - .text_size(design::t_caption(cx)) - .text_color(moon(p.text_muted)) - .child(t!("analytics.ticks.assumptions").to_string()), - ); - let entry_modelled = data.as_ref().is_some_and(|d| d.entry_modelled()); - let unmodelled: Vec = data - .as_ref() - .map(|d| d.unmodelled_kinds().into_iter().map(String::from).collect()) - .unwrap_or_default(); - // Only a LOADED empty scope says so; a load in flight or a failed one has its own note. - let no_deals = data.as_ref().is_some_and(|d| d.kinds.is_empty()); - let entry_note = if no_deals { - Some(t!("analytics.ticks.no_deals").to_string()) - } else if entry_modelled { - None - } else { - Some( - t!( - "analytics.ticks.entry_from_fact", - kinds = unmodelled.join(", ") - ) - .to_string(), - ) - }; - let entry_open = self.ticks.entry_open && entry_modelled; - let exit_open = self.ticks.exit_open; - body = body.child(self.ticks_group( - ParamGroup::Entry, - t!("analytics.ticks.group_entry").to_string(), - entry_note, - entry_open, - entry_modelled, - data.as_deref(), - p, - window, - cx, - )); - body = body.child(self.ticks_group( - ParamGroup::Exit, - t!("analytics.ticks.group_exit").to_string(), - None, - exit_open, - true, - data.as_deref(), - p, - window, - cx, - )); - let tools = self.ticks_grid_tools(cx); + let fields = scope_fields(data.as_deref()); + let mut grid = v_flex() + .w_full() + .flex_none() + .child(self.ticks_grid_header(&fields, p, cx)); + for group in [ParamGroup::Entry, ParamGroup::Exit] { + grid = grid.child(self.ticks_group_header(group, data.as_deref(), p, cx)); + if group == ParamGroup::Entry && !data.as_ref().is_some_and(|d| d.entry_modelled()) { + continue; + } + for field in fields.iter().filter(|f| f.group == group) { + let now = data.as_ref().and_then(|d| d.now.get(field.key).cloned()); + grid = grid.child(self.ticks_field_row(field.key, now, p, window, cx)); + } + } v_flex() .w_full() .flex_1() @@ -120,221 +75,200 @@ impl AnalyticsView { .flex_1() .min_h_0() .overflow_y_scroll() - .child(body), - ) - .child( - h_flex() - .w_full() - .flex_none() - .px(design::ui_px(cx, 12.0)) - .py(design::ui_px(cx, 6.0)) - .justify_end() - .child(tools), + .child(grid), ) .into_any_element() } - /// The card's accessory: "В1 → В2" and the two "clear" buttons. - fn ticks_grid_tools(&self, cx: &Context) -> AnyElement { - h_flex() - .gap(design::ui_px(cx, 4.0)) - .font_family(design::ui_font()) - .child( - MoonButton::new("an-ticks-v1-to-v2") - .variant(MoonButtonVariant::Soft) - .label(t!("analytics.ticks.v1_to_v2").to_string()) - .disabled(!self.ticks.has_changes()) - .on_click(cx.listener(|this, _, _, cx| this.ticks_copy_v1_to_v2(cx))) - .render(), - ) - .children((0..N_VAR).map(|i| { - MoonButton::new(SharedString::from(format!("an-ticks-clear-v{i}"))) - .variant(MoonButtonVariant::Soft) - .label(t!("analytics.ticks.clear_v", n = i + 1).to_string()) - .disabled(self.ticks.variants[i].is_empty()) - .on_click(cx.listener(move |this, _, _, cx| this.ticks_clear_variant(i, cx))) - .render() - })) - .into_any_element() + /// Tick or untick every field the grid shows — the header's tick. Unticked is held at its + /// base value by the search. + fn ticks_set_all(&mut self, fields: &[&'static str], on: bool, cx: &mut Context) { + for key in fields { + if on { + self.ticks.locked.remove(*key); + } else { + self.ticks.locked.insert((*key).to_string()); + } + } + self.persist_ticks_settings(cx); + cx.notify(); } - /// One group: a header line with its switch and caret, then a row per field. - #[allow(clippy::too_many_arguments)] - fn ticks_group( - &mut self, - group: ParamGroup, - title: String, - note: Option, - open: bool, - enabled: bool, - data: Option<&TicksData>, + /// The column headings: the master tick, field · strategy · В1 → ✕ · В2 ← ✕. + fn ticks_grid_header( + &self, + fields: &[&'static TickParam], p: MoonPalette, - window: &mut Window, - cx: &mut Context, + cx: &Context, ) -> AnyElement { - let (id, checkbox_id) = match group { - ParamGroup::Entry => ("an-ticks-grp-entry", "an-ticks-vary-entry"), - ParamGroup::Exit => ("an-ticks-grp-exit", "an-ticks-vary-exit"), - }; - let caret = collapse_caret( - id, - !open, - t!("analytics.ticks.group_collapse").to_string(), - t!("analytics.ticks.group_expand").to_string(), - p, - cx.listener(move |this, _, _, cx| { - match group { - ParamGroup::Entry => this.ticks.entry_open = !this.ticks.entry_open, - ParamGroup::Exit => this.ticks.exit_open = !this.ticks.exit_open, - } - cx.notify(); - }), - ); - // The "vary" switch: on by the user, allowed by the gate. - let passes = data.and_then(|d| d.group_passes(group)); - let share = data - .map(|d| match group { - ParamGroup::Entry => d.entry_share, - ParamGroup::Exit => d.exit_share, - }) - .unwrap_or((0, 0)); - let gated = enabled && passes == Some(true); - let vary_on = match group { - ParamGroup::Entry => self.ticks.vary_entry, - ParamGroup::Exit => self.ticks.vary_exit, - }; - let vary_tip = if gated { - t!("analytics.ticks.vary_tip").to_string() - } else if passes == Some(false) { - t!( - "analytics.ticks.vary_gated", - hits = share.0, - n = share.1, - gate = (SHARE_GATE * 100.0) as i64 - ) - .to_string() - } else { - t!("analytics.ticks.vary_unknown").to_string() + let keys: Vec<&'static str> = fields.iter().map(|f| f.key).collect(); + let all_on = !keys.is_empty() && keys.iter().all(|k| !self.ticks.locked.contains(*k)); + let cell = |text: String| { + div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .text_center() + .truncate() + .child(text) }; - let vary = div() - .id(SharedString::from(format!("{checkbox_id}-box"))) - .flex_none() - .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(vary_tip.clone())).into()) - .child( - MoonCheckbox::new(SharedString::from(checkbox_id)) - .label(t!("analytics.ticks.vary").to_string()) - .checked(vary_on && gated) - .disabled(!gated) - .on_change({ - let view = cx.entity(); - move |on: &bool, _w, app| { - let on = *on; - view.update(app, |this, cx| { - match group { - ParamGroup::Entry => this.ticks.vary_entry = on, - ParamGroup::Exit => this.ticks.vary_exit = on, - } - cx.notify(); - }); - } - }), - ); let mut head = h_flex() .w_full() - .px(design::ui_px(cx, 12.0)) - .py(design::ui_px(cx, 4.0)) + .px(design::ui_px(cx, 8.0)) + .h(design::fit_h_px(cx, 22.0, 12.0, 5.0)) .items_center() .gap(design::ui_px(cx, 6.0)) + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_soft)) .bg(moon(p.table_head)) - .font_family(design::ui_font()) + .child( + div().flex_none().child( + MoonCheckbox::new("an-ticks-en-all") + .checked(all_on) + .size(design::CONTROL_TIER) + .on_change({ + let view = cx.entity(); + let keys = keys.clone(); + move |on: &bool, _w, app| { + let on = *on; + view.update(app, |this, cx| this.ticks_set_all(&keys, on, cx)); + } + }), + ), + ) + // The caption toggles the lot too, as the filter grid's does — a click target rather + // than the checkbox's own label, which would widen the checkbox and push every + // heading after it out of line. .child( div() - .flex_none() - .text_size(design::t_body(cx)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(if enabled { - moon(p.text) - } else { - moon(p.text_muted) - }) - .child(title), - ); - head = match note { - Some(note) => head.child( - div() + .id("an-ticks-en-all-lbl") .flex_1() .min_w_0() .truncate() - .text_size(design::t_caption(cx)) - .text_color(moon(p.text_muted)) - .child(note), - ), - None => head.child(div().flex_1()), - }; - if enabled { - head = head.child(vary).child(div().flex_none().child(caret)); - } - let mut out = v_flex().w_full().flex_none().child(head); - if !(open && enabled) { - return out.into_any_element(); - } - // The fields every kind in the scope understands: the union over the kinds present, - // in descriptor order. - let kinds: Vec = data.map(|d| d.kinds.clone()).unwrap_or_default(); - let fields: Vec<&'static TickParam> = moon_core::db::tuner::ticks::TICK_PARAMS - .iter() - .filter(|f| f.group == group) - .filter(|f| { - kinds - .iter() - .any(|k| params_for(group, k).any(|g| g.key == f.key)) - }) - .collect(); - out = out.child(self.ticks_grid_header(p, cx)); - let now: Vec<(&'static str, Option)> = fields - .iter() - .map(|f| (f.key, data.and_then(|d| d.now.get(f.key).cloned()))) - .collect(); - for (key, now) in now { - out = out.child(self.ticks_field_row(key, now, p, window, cx)); + .cursor_pointer() + .child(t!("analytics.tuner.field").to_string()) + .on_click(cx.listener(move |this, _, _, cx| { + this.ticks_set_all(&keys, !all_on, cx); + })), + ) + .child(cell(t!("analytics.tuner.strat_chip").to_string())); + for vi in 0..N_VAR { + head = head + .child(cell(t!("analytics.ticks.var_n", n = vi + 1).to_string())) + // The only two copy buttons, both "the WHOLE column": → carries В1 into В2, ← + // В2 into В1. Rows keep a matching spacer. + .child(if vi == 0 { + glyph_btn( + "an-ticks-cp-col", + "→", + t!("analytics.time.tip_to_v2").to_string(), + p.amber, + p, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.ticks_copy_variant(0, 1, cx))) + } else { + glyph_btn( + "an-ticks-cpb-col", + "←", + t!("analytics.time.tip_to_v1").to_string(), + p.amber, + p, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.ticks_copy_variant(1, 0, cx))) + }) + .child( + glyph_btn( + SharedString::from(format!("an-ticks-clr-col-{vi}")), + "✕", + t!("analytics.time.tip_clear_all").to_string(), + p.orange, + p, + cx, + ) + .on_click(cx.listener(move |this, _, _, cx| this.ticks_clear_variant(vi, cx))), + ); } - out.into_any_element() + head.into_any_element() } - /// The column headings of a group: field · now · В1 · В2 · fix. - fn ticks_grid_header(&self, p: MoonPalette, cx: &Context) -> AnyElement { - let cell = |text: String| { - div() - .w(design::font_w_px(cx, CELL_W)) - .flex_none() - .text_right() - .child(text) + /// A group's heading: its name, and why it is not searched when it is not — the kinds whose + /// entry is taken from the fact, or a share of reproduced trades under the gate. + fn ticks_group_header( + &self, + group: ParamGroup, + data: Option<&TicksData>, + p: MoonPalette, + cx: &Context, + ) -> AnyElement { + let title = match group { + ParamGroup::Entry => t!("analytics.ticks.group_entry"), + ParamGroup::Exit => t!("analytics.ticks.group_exit"), + } + .to_string(); + let gate = self.ticks.gate(); + let note: Option<(String, u32)> = match data { + // Only a LOADED empty scope says so; a load in flight or a failed one has its own + // note in the table. + Some(d) if d.kinds.is_empty() => { + Some((t!("analytics.ticks.no_deals").to_string(), p.text_muted)) + } + Some(d) if group == ParamGroup::Entry && !d.entry_modelled() => Some(( + t!( + "analytics.ticks.entry_from_fact", + kinds = d.unmodelled_kinds().join(", ") + ) + .to_string(), + p.text_muted, + )), + Some(d) => { + let (hits, n) = d.share_of(group); + match d.group_passes(group, gate) { + Some(false) => Some(( + t!( + "analytics.ticks.vary_gated", + hits = hits, + n = n, + gate = (gate * 100.0).round() as i64 + ) + .to_string(), + p.orange, + )), + None => Some((t!("analytics.ticks.vary_unknown").to_string(), p.text_muted)), + Some(true) => None, + } + } + None => None, }; h_flex() .w_full() - .px(design::ui_px(cx, 12.0)) + .px(design::ui_px(cx, 8.0)) .py(design::ui_px(cx, 2.0)) .gap(design::ui_px(cx, 6.0)) + .items_center() + .bg(moon_alpha(p.table_head, 0.6)) + .border_t_1() + .border_color(moon_alpha(p.border, 0.7)) .text_size(design::t_caption(cx)) - .text_color(moon(p.text_soft)) - .child( - div() - .flex_1() - .child(t!("analytics.tuner.field").to_string()), - ) - .child(cell(t!("analytics.ticks.now").to_string())) - .children((0..N_VAR).map(|i| cell(t!("analytics.ticks.var_n", n = i + 1).to_string()))) - .child( - div() - .w(design::font_w_px(cx, FIX_W)) - .flex_none() - .text_center() - .child(t!("analytics.ticks.fix").to_string()), - ) + .font_family(design::ui_font()) + .child(div().flex_none().text_color(moon(p.text_soft)).child(title)) + .when_some(note, |el, (note, color)| { + el.child( + div() + .id(SharedString::from(format!("an-ticks-grp-note-{group:?}"))) + .flex_1() + .min_w_0() + .truncate() + .text_color(moon(color)) + .tooltip(crate::panels::common::text_tooltip(note.clone())) + .child(note), + ) + }) .into_any_element() } - /// One field: its key, the "now" value, an input per variant, the "fix" tick. + /// One field: its tick, its name, the strategies' value, an input per variant with its + /// clear cross. fn ticks_field_row( &mut self, key: &'static str, @@ -343,78 +277,152 @@ impl AnalyticsView { window: &mut Window, cx: &mut Context, ) -> AnyElement { - let (text, muted) = match now { - Some(NowValue::Same(v)) if !v.is_empty() => (v, false), - Some(NowValue::Same(_)) | None => ("—".to_string(), true), - Some(NowValue::Differs) => (t!("analytics.time.cur_varies").to_string(), true), - }; + let read = super::model_cfg::current().entry_method.reads(key); + let selected = self.ticks.sel_field == Some(key); + let on = !self.ticks.locked.contains(key); let inputs: Vec> = (0..N_VAR) .map(|i| self.ticks_cell_input(i, key, window, cx)) .collect(); - let locked = self.ticks.locked.contains(key); let mut row = h_flex() + .id(SharedString::from(format!("an-ticks-field-{key}"))) .w_full() - .px(design::ui_px(cx, 12.0)) + .px(design::ui_px(cx, 8.0)) .py(design::ui_px(cx, 2.0)) .items_center() .gap(design::ui_px(cx, 6.0)) .border_t_1() .border_color(moon_alpha(p.border, 0.5)) .text_size(design::t_body(cx)) - .child(div().flex_1().min_w_0().truncate().child(key)) + .when(selected, |el| el.bg(moon_alpha(p.amber, 0.08))) .child( - div() - .w(design::font_w_px(cx, CELL_W)) - .flex_none() - .text_right() - .text_color(if muted { - moon(p.text_muted) - } else { - moon(p.text) - }) - .child(text), - ); - for (i, input) in inputs.iter().enumerate() { - row = row.child( - div() - .w(design::font_w_px(cx, CELL_W)) - .flex_none() - .font_family(design::mono()) - .child( - MoonInput::new(SharedString::from(format!("an-ticks-in-v{i}-{key}"))) - .state(input) - .size(design::INPUT_SIZE), - ), - ); - } - row = row.child( - div() - .w(design::font_w_px(cx, FIX_W)) - .flex_none() - .flex() - .justify_center() - .child( - MoonCheckbox::new(SharedString::from(format!("an-ticks-fix-{key}"))) - .checked(locked) + div().flex_none().child( + MoonCheckbox::new(SharedString::from(format!("an-ticks-en-{key}"))) + .checked(on && read) + .disabled(!read) + .size(design::CONTROL_TIER) .on_change({ let view = cx.entity(); move |on: &bool, _w, app| { let on = *on; view.update(app, |this, cx| { if on { - this.ticks.locked.insert(key.to_string()); - } else { this.ticks.locked.remove(key); + } else { + this.ticks.locked.insert(key.to_string()); } + this.persist_ticks_settings(cx); cx.notify(); }); } }), ), - ); + ) + .child( + div() + .id(SharedString::from(format!("an-ticks-name-{key}"))) + .flex_1() + .min_w_0() + .truncate() + .cursor_pointer() + .text_color(if selected { + moon(p.amber) + } else if !read { + moon(p.text_muted) + } else { + moon(p.text) + }) + .child(key) + .on_click(cx.listener(move |this, _, _, cx| { + this.ticks.sel_field = Some(key); + cx.notify(); + })), + ); + // The strategies' value: a click sends it to В1 and holds the field out of the search — + // it becomes a fixed value, as the filter grid's chip makes a fixed filter. A field the + // method does not read says so instead. + row = row.child(match now { + _ if !read => div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .truncate() + .font_family(design::ui_font()) + .text_size(design::t_caption(cx)) + .text_color(moon_alpha(p.text_muted, 0.7)) + .child(t!("analytics.ticks.not_read").to_string()) + .into_any_element(), + Some(NowValue::Same(value)) if !value.is_empty() => div() + .id(SharedString::from(format!("an-ticks-chip-{key}"))) + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .truncate() + .text_right() + .cursor_pointer() + .text_color(moon(p.amber)) + .hover(move |st| st.text_color(moon(p.text))) + .child(value.clone()) + .on_click(cx.listener(move |this, _, _, cx| { + this.ticks.locked.insert(key.to_string()); + this.persist_ticks_settings(cx); + this.ticks_set_cell(0, key, value.clone(), cx); + })) + .into_any_element(), + Some(NowValue::Differs) => div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .truncate() + .text_right() + .text_color(moon(p.text_muted)) + .child(t!("analytics.time.cur_varies").to_string()) + .into_any_element(), + _ => div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .text_right() + .text_color(moon(p.text_muted)) + .child("—") + .into_any_element(), + }); + for (vi, input) in inputs.iter().enumerate() { + row = row + .child( + div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .font_family(design::mono()) + .child( + MoonInput::new(SharedString::from(format!("an-ticks-in-v{vi}-{key}"))) + .state(input) + .size(design::INPUT_SIZE), + ), + ) + // Under the header's copy arrow. + .child(div().w(design::ui_px(cx, 12.0)).flex_none()) + .child( + glyph_btn( + SharedString::from(format!("an-ticks-clr-{vi}-{key}")), + "✕", + t!("analytics.time.tip_clear").to_string(), + p.orange, + p, + cx, + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.ticks_set_cell(vi, key, String::new(), cx) + })), + ); + } row.into_any_element() } + /// Set one variant cell from outside its box — the strategy chip, a row's cross — and have + /// the box show it. + fn ticks_set_cell(&mut self, index: usize, key: &str, value: String, cx: &mut Context) { + self.set_ticks_variant(index, key, value, cx); + // The box is recreated from the stored value on the next frame. + self.ticks.inputs.remove(&format!("v{index}:{key}")); + cx.notify(); + } + /// The input box of one variant cell, created on first use from the stored value and /// kept across repaints; a change stores the value and rescores the columns. fn ticks_cell_input( @@ -460,3 +468,17 @@ impl AnalyticsView { state } } + +/// The fields the scope's kinds understand — the union over the kinds present, in descriptor +/// order. +fn scope_fields(data: Option<&TicksData>) -> Vec<&'static TickParam> { + let kinds: Vec = data.map(|d| d.kinds.clone()).unwrap_or_default(); + moon_core::db::tuner::ticks::TICK_PARAMS + .iter() + .filter(|f| { + kinds + .iter() + .any(|k| params_for(f.group, k).any(|g| g.key == f.key)) + }) + .collect() +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs index 8133209f..13f00fbc 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs @@ -33,10 +33,12 @@ fn lags() -> std::sync::MutexGuard<'static, HashMap> { /// rows: The rows of the load. /// traces: Their archived lines, by `reportuid`. /// defaults: The strategy-field defaults of the live schema. +/// model: The model settings the rows are replayed under. pub(super) fn calibrate_from( rows: &[DealRow], traces: &HashMap, defaults: &HashMap, + model: moon_core::db::tuner::ticks::ModelSettings, ) { let keys = params::param_keys(); let mut samples: HashMap> = HashMap::new(); @@ -57,10 +59,13 @@ pub(super) fn calibrate_from( ) else { continue; }; - let exit = params::exit_params(¶ms::StrategyValues { - values: &values, - defaults, - }); + let exit = params::exit_params( + ¶ms::StrategyValues { + values: &values, + defaults, + }, + model, + ); samples .entry(row.deal.core_uid) .or_default() diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 5f97dbef..25b110f8 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -1,10 +1,9 @@ //! Background loads of the "Entry/Exit" axis, in two stages. //! -//! Stage A reads the scope's deals, the whole-scope "Fact" KPI (the same SQL every axis' -//! "Fact" comes from) and the grid's "now" values off the database. Its completion resolves, -//! on the UI thread, where each deal's prints live — the core's exchange key and the coin's -//! market, which only the live market source knows — and publishes the rows at once, without -//! their tape (stage B). Stage C then asks the replay worker for the held tape of every row in +//! Stage A reads the scope's deals and the grid's "now" values off the database. Its +//! completion resolves, on the UI thread, where each deal's prints live — the core's exchange +//! key and the coin's market, which only the live market source knows — and publishes the rows +//! at once, without their tape (stage B). Stage C then asks the replay worker for the held tape of every row in //! ONE batch of queries, reads the archived entry lines, runs the model on the parameters as //! of the buy, and folds the answers into the published rows. In one batch because it is one //! round trip for the table: the worker's coordinator answers held queries off no venue call, @@ -27,10 +26,10 @@ use crate::analytics::refresh::{CatchUpOutcome, report_result_is_stale}; use moon_core::db::ReadFail; use moon_core::db::order_traces::{TraceEntry, read_many}; use moon_core::db::tuner::ticks::{ - Deal, DealsRead, EntryParams, OwnLines, deltas, entry_model_for, infer_tick, model_window, - params, prepare_deal, required_spans, verify, + Deal, DealsRead, EntryParams, ModelSettings, OwnLines, deltas, entry_model_for, infer_tick, + model_window, params, prepare_deal, required_spans, verify, }; -use moon_core::db::tuner::{VarStats, Variant, strategy_current_values, strategy_values_at}; +use moon_core::db::tuner::{strategy_current_values, strategy_values_at}; use moon_core::feed::report_traces::ArchivedLineKind; use moon_core::feed::types::Tick; use moon_core::market::kline_cache::KlineCache; @@ -45,12 +44,8 @@ use moon_core::market::trade_replay::{ /// stalls — past it the rows still unanswered fold as missing, and the log says how many. const HELD_ANSWER_WAIT: Duration = Duration::from_secs(240); -/// What stage A brings back: the deals, the "Fact" KPI and the grid's "now" values. -type StageA = ( - Result, - Result, ReadFail>, - HashMap, -); +/// What stage A brings back: the deals and the grid's "now" values. +type StageA = (Result, HashMap); impl AnalyticsView { /// Recompute the axis for the current scope. @@ -83,6 +78,9 @@ impl AnalyticsView { ReadLane::TicksSearch, ]); self.ticks.seq = self.ticks.seq.wrapping_add(1); + // The tape stage the cancel above dropped is not reading any more; the new load's own + // stage C raises the flag again when it starts. + self.ticks.tape_reading = false; // A search over the previous deal set answers nothing about the new one; the lane // cancel above does not reach its handle, only this does. self.ticks.stop_search(); @@ -122,17 +120,16 @@ impl AnalyticsView { q.strategies.len() ), } - let fact = moon_core::db::tuner::variant_stats(&q, &[Variant::default()]); let now = now_values(&targets, &keys); - (deals, fact, now) + (deals, now) }, - move |this, (deals, fact, now): StageA, cx| { + move |this, (deals, now): StageA, cx| { if this.ticks.seq != req { return; } - let (read, fact) = match (deals, fact) { - (Ok(read), Ok(fact)) => (read, fact), - (Err(error), _) | (_, Err(error)) => { + let read = match deals { + Ok(read) => read, + Err(error) => { let outcome = CatchUpOutcome::of_read::<()>(&Err(error.clone())); this.ticks.dirty = report_result_is_stale( report_req, @@ -149,16 +146,7 @@ impl AnalyticsView { } }; let addresses = this.resolve_addresses(&read.deals, cx); - this.start_replay_stage( - req, - report_req, - after_report, - read, - fact, - now, - addresses, - cx, - ); + this.start_replay_stage(req, report_req, after_report, read, now, addresses, cx); }, ); } @@ -178,19 +166,41 @@ impl AnalyticsView { .collect() } - /// Stage B: the rows, published at once without their tape; stage C follows. - #[allow(clippy::too_many_arguments)] + /// Stage B: the rows, published at once — each with what the last load already judged of + /// it, when that still holds ([`carryable`]), else without its tape; stage C follows for + /// the rest. + /// + /// A reload comes every time the report moves — a trade closing on any core, every few + /// seconds on a busy fleet — and used to publish every row blank and read and replay the + /// whole table again: the table and the KPI blinked empty for the length of that, and the + /// work grew with the table, not with what changed. A row the model already judged under + /// the settings in force keeps its verdict; stage C reads and replays only the others. fn start_replay_stage( &mut self, req: u64, report_req: u64, after_report: bool, read: DealsRead, - fact: Vec, now: HashMap, addresses: HashMap<(u64, String), Option>>, cx: &mut Context, ) { + let model = super::model_cfg::current(); + let judged: HashMap = if self.ticks.judged_under == Some(model) { + self.ticks + .data + .data() + .map(|d| { + d.rows + .iter() + .filter(|r| matches!(r.tape, TapeStatus::Covered | TapeStatus::Refused(_))) + .map(|r| (r.deal.report_uid, r.clone())) + .collect() + }) + .unwrap_or_default() + } else { + HashMap::new() + }; self.spawn_latest_db( &[ReadLane::TicksReplay], false, @@ -204,7 +214,11 @@ impl AnalyticsView { .get(&(deal.core_uid, deal.coin.clone())) .cloned() .flatten(); - DealRow { + let before = judged + .get(&deal.report_uid) + .filter(|before| address.is_some() && carryable(&before.deal, &deal)) + .cloned(); + let mut row = DealRow { deal, tape: if address.is_some() { TapeStatus::Missing @@ -216,9 +230,16 @@ impl AnalyticsView { ticks: None, entry_line: None, held: None, + }; + if let Some(before) = before { + row.take_replay(before); } + row }) .collect(); + let carried = rows + .iter() + .any(|r| matches!(r.tape, TapeStatus::Covered | TapeStatus::Refused(_))); let mut kinds: Vec = rows.iter().map(|r| r.deal.kind.clone()).collect(); kinds.sort(); kinds.dedup(); @@ -227,7 +248,7 @@ impl AnalyticsView { without_ms: read.without_ms, service: read.service, untunable: read.untunable, - kpi: fact, + kpi: Vec::new(), entry_share: (0, 0), exit_share: (0, 0), kinds, @@ -235,9 +256,9 @@ impl AnalyticsView { }; data.retain_within_cap(); data.refresh_summary(); - data + (data, carried) }, - move |this, data, cx| { + move |this, (data, carried), cx| { if this.ticks.seq != req { return; } @@ -249,7 +270,7 @@ impl AnalyticsView { if super::fetch::job::progress().active { this.attach_fetch_listener(cx); } - this.start_tape_stage(req, cx); + this.start_tape_stage(req, carried.then_some(model), cx); if after_report { this.settle_report_refresh_retry(false, cx); } @@ -258,19 +279,66 @@ impl AnalyticsView { ); } + /// The model's settings changed: judge every row again under them — stage C alone, since + /// the deals and their tape are what they were. A load still before its stage C reads the + /// new settings when it gets there and is left alone: cancelling its lane would drop the + /// rows it is about to publish. + pub(in crate::analytics::tuner) fn ticks_replay_again(&mut self, cx: &mut Context) { + if !matches!(self.ticks.data, crate::load_state::LoadState::Ready(_)) { + return; + } + self.latest_reads.cancel(&[ + ReadLane::TicksReplay, + ReadLane::TicksVariants, + ReadLane::TicksSearch, + ]); + self.ticks.stop_search(); + // Every verdict of the table is of the old settings now: none may be carried by a + // reload until this stage has judged them all again. + self.ticks.judged_under = None; + let req = self.ticks.seq; + self.start_tape_stage(req, None, cx); + } + /// Stage C: the held tape of every published row, asked from the worker in one batch, the /// archived entry lines, and the model on the parameters as of the buy — folded into the /// rows when all of it is in. - fn start_tape_stage(&mut self, req: u64, cx: &mut Context) { + /// + /// Args: + /// req: The load generation this stage belongs to. + /// carried: The settings the rows stage B carried were judged under, when it carried + /// any: those rows are not read again. `None` reads and replays every row. + fn start_tape_stage( + &mut self, + req: u64, + carried: Option, + cx: &mut Context, + ) { + // One set of model settings for the whole stage, read once: every row of a table is + // judged by the same rules. + let model = super::model_cfg::current(); + // Rows carried under settings changed since stage B read them are not carried: the + // stage reads and replays them too, so the table folds under `model` alone. + let carried = carried.filter(|m| *m == model); + // This stage's own generation: a stage started after it — a changed setting re-judging + // the table under the same load — makes its answer stale, even though a cancelled + // stage still hands its partial rows to `store` (`spawn_latest_db`). + self.ticks.tape_seq = self.ticks.tape_seq.wrapping_add(1); + let tape_req = self.ticks.tape_seq; let Some(data) = self.ticks.data.data() else { return; }; let targets: Vec<(Deal, Arc)> = data .rows .iter() + .filter(|r| { + carried.is_none() || !matches!(r.tape, TapeStatus::Covered | TapeStatus::Refused(_)) + }) .filter_map(|r| Some((r.deal.clone(), r.address.clone()?))) .collect(); if targets.is_empty() { + self.ticks.tape_reading = false; + self.ticks.judged_under = Some(model); return; } let defaults = self.filter_defaults(cx); @@ -304,13 +372,13 @@ impl AnalyticsView { .collect(); let mut traces = archived_lines(&rows); // Before any row is replayed: each core's step lag, off these rows' archives. - super::lags::calibrate_from(&rows, &traces, &defaults); + super::lags::calibrate_from(&rows, &traces, &defaults, model); let now_ms = moon_core::util::now_unix_ms_i64(); for row in &mut rows { let lines = traces.remove(&row.deal.report_uid).unwrap_or_default(); let tape = tapes.remove(&row.deal.report_uid); let answered = tape.is_some(); - replay_row_with(row, &defaults, lines, tape, klines.as_ref()); + replay_row_with(row, &defaults, model, lines, tape, klines.as_ref()); // Said at load, not after a walk: a row the venue cannot serve is not // "missing" — it would only ever come back refused. The fetch job's own // path (`replay_row` after a walk) is NOT given this: its retries and its @@ -341,16 +409,24 @@ impl AnalyticsView { .get(&row.deal.report_uid) .cloned() .unwrap_or_default(); - replay_row(row, &defaults, lines, long_position_ms, klines.as_ref()); + replay_row( + row, + &defaults, + model, + lines, + long_position_ms, + klines.as_ref(), + ); } } rows }, move |this, rows, cx| { - if this.ticks.seq != req { + if this.ticks.seq != req || this.ticks.tape_seq != tape_req { return; } this.ticks.tape_reading = false; + this.ticks.judged_under = Some(model); this.ticks.update_rows(rows); // The row the fetch job is out for says so again after the fold. this.mark_fetch_in_flight(); @@ -554,6 +630,7 @@ fn unservable_status(address: &RowAddress, deal: &Deal, now_ms: i64) -> Option, + model: ModelSettings, lines: ArchivedLines, long_position_ms: i64, klines: Option<&KlineCache>, @@ -562,7 +639,7 @@ pub(super) fn replay_row( .address .as_ref() .and_then(|address| held_tape(address, &row.deal, long_position_ms)); - replay_row_with(row, defaults, lines, tape, klines); + replay_row_with(row, defaults, model, lines, tape, klines); } /// Run the model on one row from a tape already asked for. A covered row keeps its tape and @@ -574,6 +651,7 @@ pub(super) fn replay_row( pub(super) fn replay_row_with( row: &mut DealRow, defaults: &HashMap, + model: ModelSettings, lines: ArchivedLines, tape: Option, klines: Option<&KlineCache>, @@ -619,14 +697,11 @@ pub(super) fn replay_row_with( defaults, }; let entry = if entry_model_for(&row.deal.kind) { - EntryParams::MoonShot(params::mshot_params( - &sv, - moon_core::db::tuner::ticks::mshot::DEFAULT_LATENCY_MS, - )) + EntryParams::MoonShot(params::mshot_params(&sv, model)) } else { EntryParams::Fact }; - let exit = params::exit_params(&sv); + let exit = params::exit_params(&sv, model); // The deltas along the window, before the record's inputs: the stop anchor reads the stop // through them. row.deal.delta_track = klines.and_then(|cache| { @@ -665,3 +740,17 @@ pub(super) fn replay_row_with( )); row.ticks = Some(Arc::from(ticks)); } + +/// Whether what the last load judged of a trade still describes the row a reload read for it: +/// the same trade, with the same stamps and prices the model reads. A report row rewritten under +/// the same uid — a close booked late, a price corrected — is judged afresh. +pub(super) fn carryable(before: &Deal, now: &Deal) -> bool { + before.report_uid == now.report_uid + && before.core_uid == now.core_uid + && before.strategy_id == now.strategy_id + && before.buy_ms == now.buy_ms + && before.close_ms == now.close_ms + && before.buy_price == now.buy_price + && before.sell_price == now.sell_price + && before.order_open_ms() == now.order_open_ms() +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index ca2f64e0..4e3e9f8d 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -6,10 +6,11 @@ //! reproduces the fact; a double-click opens the trade window on it. Every row of the scope is //! shown; the switch under the table narrows it to the sample the variants and the search run //! on — the rows whose tape covers the window AND which the model reproduces (`DealRow::fit`) — -//! and the status line says how many that is out of the scope. Right: the shared "Fact vs …" -//! matrix (the whole scope, the fit subset captioned with the ✓ shares, the variant columns), -//! and the parameter grid with the strategies' values, the two variant columns and the search -//! row. +//! and the status line says how many that is out of the scope; the table's head opens the model's +//! settings (`cfg.rs`), which decide its ✓ column. Right: the shared KPI matrix over the fit rows +//! (captioned with the ✓ shares) and the two variant columns, and the parameter grid laid out as +//! the "By filter" one — the strategies' values, the variant columns, the search row with its +//! settings popover. //! //! The model itself is `moon_core::db::tuner::ticks`; this module only feeds it and draws //! what it says. @@ -23,24 +24,23 @@ use moon_ui::{ use rust_i18n::t; use super::super::AnalyticsView; -use super::kpi::{VarLabel, kpi_matrix_card}; -use super::shared::TunerKind; -use super::shell::CfgInput; +use super::kpi::{VarLabel, kpi_matrix_card_over}; use super::{sort_arrow_of, toggle_sort_key}; use crate::design; use crate::design::{moon, moon_alpha}; use columns::*; pub(in crate::analytics::tuner) use fetch::strategy_field_defaults; use moon_core::market::trade_replay::TickStatus; -use state::SuggState; use state::{DealRow, TapeStatus}; +mod cfg; pub(in crate::analytics::tuner) mod columns; mod delta_summary; pub(crate) mod fetch; mod grid; mod lags; mod load; +pub(in crate::analytics) mod model_cfg; pub(in crate::analytics::tuner) mod rows; pub(in crate::analytics) mod state; mod variants; @@ -51,6 +51,7 @@ impl AnalyticsView { pub(in crate::analytics::tuner) fn ticks_card( &mut self, p: MoonPalette, + window: &mut Window, cx: &mut Context, ) -> AnyElement { let scale = design::font_scale(cx); @@ -239,6 +240,7 @@ impl AnalyticsView { } else { t!("analytics.ticks.fetch_btn").to_string() }; + let model_settings = self.ticks_model_settings(p, window, cx); v_flex() .w_full() .flex_1() @@ -310,7 +312,13 @@ impl AnalyticsView { .render(), ), ) - }), + }) + .child( + div() + .flex_none() + .font_family(design::ui_font()) + .child(model_settings), + ), ) .child(self.deal_header(p, cx)) // The virtual list owns its own scrolling. @@ -452,10 +460,11 @@ impl AnalyticsView { .into_any_element() } - /// "Fact vs …": the whole scope, the rows fit for the search (captioned with how many of the - /// covered ones that is, and the ✓ shares of both groups — the model's own account of - /// itself), then the variant columns, each over the replayable rows and captioned with how - /// many. + /// The KPI matrix: the rows fit for the search as the baseline — captioned with how many of + /// the covered ones that is, the ✓ shares of both groups (the model's own account of itself) + /// and the exit horizon — then В1 and В2 over the rows whose tape is in memory. The whole + /// scope is not a column: the axis works on the fit rows alone. An untouched variant is the + /// strategy as it stands, and shows the baseline. fn ticks_kpi(&self, p: MoonPalette, cx: &Context) -> AnyElement { let (fit, covered, entry, exit, replayable, horizon) = self .ticks @@ -492,20 +501,19 @@ impl AnalyticsView { if let Some(horizon) = horizon { subset_sub.push_str(&t!("analytics.ticks.horizon", h = duration_text(horizon))); } - let mut labels = vec![VarLabel::with_sub( - t!("analytics.ticks.subset").to_string(), - subset_sub, - )]; - // The matrix reads one vector: `[fact, subset]` from the load, then the variants that - // were scored. An untouched variant is not a column. - let mut stats: Vec = self - .ticks - .kpi - .data() - .map(|k| k.to_vec()) - .unwrap_or_default(); + let base = VarLabel::with_sub(t!("analytics.ticks.subset").to_string(), subset_sub); + let baseline: Option = + self.ticks.kpi.data().and_then(|k| k.first().cloned()); + let mut labels = Vec::with_capacity(self.ticks.var_stats.len()); + let mut stats: Vec = baseline.iter().cloned().collect(); for (i, var) in self.ticks.var_stats.iter().enumerate() { + let title = t!("analytics.ticks.var_n", n = i + 1).to_string(); let Some(var) = var else { + labels.push(VarLabel::with_sub( + title, + t!("analytics.ticks.var_untouched").to_string(), + )); + stats.extend(baseline.iter().cloned()); continue; }; let mut sub = t!( @@ -531,13 +539,22 @@ impl AnalyticsView { ); } } - labels.push(VarLabel::with_sub( - t!("analytics.ticks.var_n", n = i + 1).to_string(), - sub, - )); + labels.push(VarLabel::with_sub(title, sub)); stats.push(var.clone()); } let state = match &self.ticks.kpi { + crate::load_state::LoadState::NotReady => crate::load_state::LoadState::NotReady, + crate::load_state::LoadState::Failed(e) => { + crate::load_state::LoadState::Failed(e.clone()) + } + // The matrix reads column 0 in every row: without a baseline there is nothing to + // draw yet, whatever else the load state says. Nor while the first tape stage of a + // table judges it and no row is fit yet: the baseline would read as a real + // "0 trades". A reload carries the judged rows (`load.rs`, stage B), so the numbers + // stay on screen while it reads the rest. + _ if stats.is_empty() || (self.ticks.tape_reading && fit == 0) => { + crate::load_state::LoadState::Loading { stale: None } + } crate::load_state::LoadState::Ready(_) => { crate::load_state::LoadState::Ready(std::sync::Arc::new(stats)) } @@ -546,143 +563,17 @@ impl AnalyticsView { stale: stale.as_ref().map(|_| std::sync::Arc::new(stats)), } } - crate::load_state::LoadState::NotReady => crate::load_state::LoadState::NotReady, - crate::load_state::LoadState::Failed(e) => { - crate::load_state::LoadState::Failed(e.clone()) - } }; - kpi_matrix_card( + kpi_matrix_card_over( &state, self.scope_label(), + &base, &labels, self.kpi_collapsed, p, cx, ) } - - /// The search row of the axis: restarts, minimum trades, the train share, the status, - /// Stop and "Search". - pub(in crate::analytics::tuner) fn ticks_config_row( - &mut self, - p: MoonPalette, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let running = matches!(self.ticks.sugg, SuggState::Running { .. }); - let (status, status_color) = match &self.ticks.sugg { - // Counted against the restarts the run was launched with, not the box's current - // text. - SuggState::Running { handle, total } => ( - t!( - "analytics.tuner.sugg_progress", - done = handle.completed(), - total = total - ) - .to_string(), - p.text_soft, - ), - SuggState::Idle => match &self.ticks.sugg_note { - Some(note) => (note.clone(), p.amber), - None => (String::new(), p.text_muted), - }, - }; - let it_placeholder = variants::DEFAULT_RESTARTS.to_string(); - let it_input = self.shell_cfg_input( - TunerKind::Ticks, - CfgInput::Restarts, - &it_placeholder, - window, - cx, - ); - let mn_input = - self.shell_cfg_input(TunerKind::Ticks, CfgInput::MinTrades, "auto", window, cx); - let train_pct = self.ticks.train_pct; - let tr_view = cx.entity(); - let tr_items = crate::panels::radio_items( - super::filter::state::TRAIN_OPTIONS.map(|n| { - ( - n, - SharedString::from(format!("tun-tr-x-{n}")), - SharedString::from(super::shell::train_label(n)), - ) - }), - train_pct, - crate::panels::RadioMark::Highlight, - move |app, n| { - tr_view.update(app, |this, cx| { - this.ticks.train_pct = n; - cx.notify(); - }); - }, - ); - let tr_combo = moon_ui::MoonDropdown::new(SharedString::from("tun-cfg-tr-x")) - .label(super::shell::train_label(train_pct)) - .trigger_caret(true) - .trigger_variant(MoonButtonVariant::Soft) - .trigger_size(moon_ui::MoonButtonSize::density(cx)) - .menu_width_scaled(96.0) - .items(tr_items); - let input_box = |id: &'static str, state: &Entity, w: f32| { - div() - .w(design::font_w_px(cx, w)) - .flex_none() - .font_family(design::mono()) - .child( - moon_ui::MoonInput::new(SharedString::from(id)) - .state(state) - .size(design::INPUT_SIZE), - ) - }; - h_flex() - .w_full() - .flex_none() - .px(design::ui_px(cx, 12.0)) - .pb(design::ui_px(cx, 6.0)) - .items_center() - .gap(design::ui_px(cx, 6.0)) - .text_size(design::t_caption(cx)) - .font_family(design::ui_font()) - .child( - div() - .text_color(moon(p.text_muted)) - .child(t!("analytics.tuner.iters").to_string()), - ) - .child(input_box("tun-cfg-it-x", &it_input, 46.0)) - .child( - div() - .text_color(moon(p.text_muted)) - .child(t!("analytics.tuner.min_trades").to_string()), - ) - .child(input_box("tun-cfg-mn-x", &mn_input, 46.0)) - .child(tr_combo) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_color(moon(status_color)) - .child(status), - ) - .when(running, |el| { - el.child( - MoonButton::new("tun-suggest-stop-x") - .variant(MoonButtonVariant::Soft) - .label(t!("analytics.tuner.stop").to_string()) - .on_click(cx.listener(|this, _, _, cx| this.ticks_stop_suggest(cx))) - .render(), - ) - }) - .child( - MoonButton::new("tun-suggest-run-x") - .variant(MoonButtonVariant::Blue) - .label(t!("analytics.tuner.suggest_run").to_string()) - .disabled(running) - .on_click(cx.listener(|this, _, _, cx| this.ticks_suggest(cx))) - .render(), - ) - .into_any_element() - } } /// The heading of one column. The profit column names its unit — the cells are bare numbers, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/model_cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/model_cfg.rs new file mode 100644 index 00000000..b733fbc8 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/model_cfg.rs @@ -0,0 +1,228 @@ +//! The model's settings (`moon_core::db::tuner::ticks::ModelSettings`), held for the whole +//! process like the step lags (`lags.rs`): every path that replays a row — the load, the fetch +//! job, the startup autoload — and the variant columns and the search read them from here, so a +//! row's ✓ never depends on which path replayed it. Seeded from the saved layout when an +//! analytics view opens; written by the model settings popover. +//! +//! Also the popover's field list: which setting, under which caption, in which unit. + +use std::sync::{Mutex, OnceLock}; + +use moon_core::db::tuner::ticks::ModelSettings; + +/// The settings in force. +static MODEL: OnceLock> = OnceLock::new(); + +fn store() -> std::sync::MutexGuard<'static, ModelSettings> { + MODEL + .get_or_init(Default::default) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// The settings every replay runs on, sanitized. +pub(in crate::analytics) fn current() -> ModelSettings { + *store() +} + +/// Put `settings` in force; answers whether anything changed. +pub(in crate::analytics) fn replace(settings: ModelSettings) -> bool { + let settings = settings.sanitized(); + let mut slot = store(); + let changed = *slot != settings; + *slot = settings; + changed +} + +/// Which part of the model a setting belongs to — the popover's sections. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Section { + Entry, + Take, + Stop, + Line, + Verdict, +} + +impl Section { + /// The locale key of the section's heading. + pub(super) fn title_key(self) -> &'static str { + match self { + Section::Entry => "analytics.ticks.model_sec_entry", + Section::Take => "analytics.ticks.model_sec_take", + Section::Stop => "analytics.ticks.model_sec_stop", + Section::Line => "analytics.ticks.model_sec_line", + Section::Verdict => "analytics.ticks.model_sec_verdict", + } + } +} + +/// One numeric setting of the popover. +pub(super) struct ModelField { + /// The input box's cache key and element id suffix. + pub(super) id: &'static str, + /// Locale keys of the caption and its tooltip. + pub(super) label: &'static str, + pub(super) tip: &'static str, + pub(super) section: Section, + /// Whole milliseconds (`true`) or a per-cent tolerance. + pub(super) whole: bool, + pub(super) get: fn(&ModelSettings) -> f64, + pub(super) set: fn(&mut ModelSettings, f64), +} + +/// Every numeric setting, in the popover's order. The entry method is not here: it changes how +/// a variant is replayed, never the ✓ of the fact, and sits with the search settings. +pub(super) const MODEL_FIELDS: &[ModelField] = &[ + ModelField { + id: "latency", + label: "analytics.ticks.model_latency", + tip: "analytics.ticks.model_latency_tip", + section: Section::Entry, + whole: true, + get: |m| m.latency_ms, + set: |m, v| m.latency_ms = v, + }, + ModelField { + id: "replace-window", + label: "analytics.ticks.model_replace_window", + tip: "analytics.ticks.model_replace_window_tip", + section: Section::Entry, + whole: true, + get: |m| m.replace_window_ms as f64, + set: |m, v| m.replace_window_ms = v as i64, + }, + ModelField { + id: "shift-window", + label: "analytics.ticks.model_shift_window", + tip: "analytics.ticks.model_shift_window_tip", + section: Section::Entry, + whole: true, + get: |m| m.shift_window_ms as f64, + set: |m, v| m.shift_window_ms = v as i64, + }, + ModelField { + id: "pre-spike", + label: "analytics.ticks.model_pre_spike", + tip: "analytics.ticks.model_pre_spike_tip", + section: Section::Take, + whole: true, + get: |m| m.pre_spike_lookback_ms as f64, + set: |m, v| m.pre_spike_lookback_ms = v as i64, + }, + ModelField { + id: "ticker", + label: "analytics.ticks.model_ticker", + tip: "analytics.ticks.model_ticker_tip", + section: Section::Stop, + whole: true, + get: |m| m.ticker_period_ms as f64, + set: |m, v| m.ticker_period_ms = v as i64, + }, + ModelField { + id: "series", + label: "analytics.ticks.model_series", + tip: "analytics.ticks.model_series_tip", + section: Section::Stop, + whole: true, + get: |m| m.series_tick_ms as f64, + set: |m, v| m.series_tick_ms = v as i64, + }, + ModelField { + id: "step-floor", + label: "analytics.ticks.model_step_floor", + tip: "analytics.ticks.model_step_floor_tip", + section: Section::Line, + whole: true, + get: |m| m.step_floor_ms as f64, + set: |m, v| m.step_floor_ms = v as i64, + }, + ModelField { + id: "pump-lag", + label: "analytics.ticks.model_pump_lag", + tip: "analytics.ticks.model_pump_lag_tip", + section: Section::Line, + whole: true, + get: |m| m.pump_move_lag_ms as f64, + set: |m, v| m.pump_move_lag_ms = v as i64, + }, + ModelField { + id: "pump-peak", + label: "analytics.ticks.model_pump_peak", + tip: "analytics.ticks.model_pump_peak_tip", + section: Section::Line, + whole: true, + get: |m| m.pump_peak_lookback_ms as f64, + set: |m, v| m.pump_peak_lookback_ms = v as i64, + }, + ModelField { + id: "point-time", + label: "analytics.ticks.model_point_time", + tip: "analytics.ticks.model_point_time_tip", + section: Section::Verdict, + whole: true, + get: |m| m.point_time_ms as f64, + set: |m, v| m.point_time_ms = v as i64, + }, + ModelField { + id: "book-stop-time", + label: "analytics.ticks.model_book_stop_time", + tip: "analytics.ticks.model_book_stop_time_tip", + section: Section::Verdict, + whole: true, + get: |m| m.book_stop_time_ms as f64, + set: |m, v| m.book_stop_time_ms = v as i64, + }, + ModelField { + id: "price", + label: "analytics.ticks.model_price", + tip: "analytics.ticks.model_price_tip", + section: Section::Verdict, + whole: false, + get: |m| m.price_pct, + set: |m, v| m.price_pct = v, + }, + ModelField { + id: "stop-price", + label: "analytics.ticks.model_stop_price", + tip: "analytics.ticks.model_stop_price_tip", + section: Section::Verdict, + whole: false, + get: |m| m.stop_price_pct, + set: |m, v| m.stop_price_pct = v, + }, + ModelField { + id: "fill-better", + label: "analytics.ticks.model_fill_better", + tip: "analytics.ticks.model_fill_better_tip", + section: Section::Verdict, + whole: false, + get: |m| m.fill_improvement_pct, + set: |m, v| m.fill_improvement_pct = v, + }, +]; + +/// A setting's value as its box shows it: whole milliseconds without a fraction, a tolerance +/// with as many decimals as it needs. +pub(super) fn field_text(field: &ModelField, settings: &ModelSettings) -> String { + let value = (field.get)(settings); + if field.whole { + format!("{value:.0}") + } else { + let text = format!("{value:.4}"); + text.trim_end_matches('0').trim_end_matches('.').to_string() + } +} + +/// A typed value, read as the model reads it: a number with a comma or a point, never negative; +/// `None` for anything else, which leaves the setting as it was. +pub(super) fn parse_field(text: &str) -> Option { + text.trim() + .replace(',', ".") + .parse::() + .ok() + .filter(|v| v.is_finite() && *v >= 0.0) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/model_cfg/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/model_cfg/tests.rs new file mode 100644 index 00000000..7791084c --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/model_cfg/tests.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn every_field_reads_back_what_it_writes() { + for field in MODEL_FIELDS { + let mut settings = ModelSettings::default(); + (field.set)(&mut settings, 7.0); + assert_eq!((field.get)(&settings), 7.0, "{}", field.id); + } +} + +#[test] +fn a_setting_reads_as_its_box_shows_it() { + let settings = ModelSettings::default(); + let latency = MODEL_FIELDS.iter().find(|f| f.id == "latency").unwrap(); + assert_eq!(field_text(latency, &settings), "100"); + let price = MODEL_FIELDS.iter().find(|f| f.id == "price").unwrap(); + assert_eq!(field_text(price, &settings), "0.05"); + assert_eq!(parse_field("0,3"), Some(0.3)); + assert_eq!(parse_field("-1"), None); + assert_eq!(parse_field("abc"), None); +} + +#[test] +fn every_field_has_a_distinct_id() { + let mut ids: Vec<&str> = MODEL_FIELDS.iter().map(|f| f.id).collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), MODEL_FIELDS.len()); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 6e3814a2..525f64c8 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -235,11 +235,13 @@ fn variant_edits_fold_to_sorted_changes_and_empty_cells_clear() { fn the_share_gate_answers_per_group_and_only_once_something_answered() { use moon_core::db::tuner::ticks::params::ParamGroup; let mut data = TicksData::default(); - assert_eq!(data.group_passes(ParamGroup::Entry), None); + assert_eq!(data.group_passes(ParamGroup::Entry, 0.8), None); data.entry_share = (8, 10); data.exit_share = (7, 10); - assert_eq!(data.group_passes(ParamGroup::Entry), Some(true)); - assert_eq!(data.group_passes(ParamGroup::Exit), Some(false)); + assert_eq!(data.group_passes(ParamGroup::Entry, 0.8), Some(true)); + assert_eq!(data.group_passes(ParamGroup::Exit, 0.8), Some(false)); + // The gate is the search settings' own: lowered, the exit group passes too. + assert_eq!(data.group_passes(ParamGroup::Exit, 0.7), Some(true)); data.kinds = vec!["MoonShot".into()]; assert_eq!(data.single_kind(), Some("MoonShot")); data.kinds.push("Spread".into()); @@ -265,3 +267,97 @@ fn invalidate_stops_the_search_and_drops_the_variant_scores_but_keeps_the_edits( "the user's edits survive a scope change" ); } + +/// The gate reads the typed per cent, falls back to the default on anything else, and never +/// goes past the whole. +#[test] +fn the_gate_reads_the_typed_percent() { + let mut state = TicksState::default(); + assert_eq!( + state.gate(), + f64::from(super::super::state::DEFAULT_GATE_PCT) / 100.0 + ); + state.gate_pct = "65".into(); + assert_eq!(state.gate(), 0.65); + state.gate_pct = "250".into(); + assert_eq!(state.gate(), 1.0); + state.gate_pct = "abc".into(); + assert_eq!(state.gate(), 0.8); +} + +/// What the layout keeps of the axis comes back as it went out. +#[test] +fn the_axis_settings_restore_what_they_saved() { + let mut state = TicksState::default(); + state.iters = "40".into(); + state.seed = "123".into(); + state.passes = "8".into(); + state.gate_pct = "70".into(); + state.train_pct = 80; + state.locked.insert("SellPrice".into()); + let saved = state.saved(); + let mut back = TicksState::default(); + back.restore(&saved); + assert_eq!(back.iters, "40"); + assert_eq!(back.seed, "123"); + assert_eq!(back.passes, "8"); + assert_eq!(back.gate_pct, "70"); + assert_eq!(back.train_pct, 80); + assert!(back.locked.contains("SellPrice")); + // A seed that is not a number is not kept. + state.seed = "x1".into(); + assert_eq!(state.saved().seed, None); +} + +/// A reload carries what the last load judged only for the same trade with the same stamps and +/// prices; a row rewritten under its uid is judged again. +#[test] +fn a_reload_carries_only_the_trade_it_judged() { + let before = deal(7, 1_000, 100.0, 101.0, false); + assert!(super::super::load::carryable(&before, &before.clone())); + let mut moved = before.clone(); + moved.close_ms += 1; + assert!(!super::super::load::carryable(&before, &moved)); + let mut repriced = before.clone(); + repriced.sell_price = 101.5; + assert!(!super::super::load::carryable(&before, &repriced)); + let other = deal(8, 1_000, 100.0, 101.0, false); + assert!(!super::super::load::carryable(&before, &other)); +} + +/// The fetch job's words on a hop land in order and in one pass: a mark, then that row's +/// answer, leaves the answer; a stop unmarks only what is still fetching; a row not in the +/// table is skipped. +#[test] +fn the_fetch_edits_land_in_order_in_one_pass() { + use super::super::state::RowEdit; + let mut state = state(); + let answer = DealRow { + deal: deal(1, 3_000, 100.0, 101.0, false), + tape: TapeStatus::Covered, + verdict: Some(verdict(Some(true), Some(true))), + address: None, + ticks: None, + entry_line: None, + held: Some((60_000, 60_000)), + }; + state.edit_rows([ + (1, RowEdit::MarkFetching), + (1, RowEdit::Replay(Box::new(answer))), + (2, RowEdit::UnmarkFetching), + (99, RowEdit::MarkFetching), + ]); + let data = state.data.data().expect("data"); + let row = |uid: i64| { + data.rows + .iter() + .find(|r| r.deal.report_uid == uid) + .expect("row") + }; + assert_eq!(row(1).tape, TapeStatus::Covered); + assert!(row(1).verdict.is_some()); + // Covered, not fetching: the unmark leaves it alone. + assert_eq!(row(2).tape, TapeStatus::Covered); + // The answer was counted: both judged rows are fit now. + assert_eq!(data.fit(), 2); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index b36dfeb8..3bb2acf7 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -18,14 +18,14 @@ use moon_core::db::tuner::VarStats; use moon_core::db::tuner::threshold_search::SearchHandle; use moon_core::db::tuner::ticks::params::ParamGroup; use moon_core::db::tuner::ticks::search::SearchResult; -use moon_core::db::tuner::ticks::{Deal, EntryMethod, Verdict, fit_for_search}; +use moon_core::db::tuner::ticks::{Deal, Verdict, fit_for_search}; use moon_core::feed::types::Tick; use moon_core::market::trade_replay::TickStatus; -/// Share of hits a group needs before it may be searched: a model that cannot reproduce the -/// fact must not be asked what would have been better. The spec's proposal (80 %), to be tuned -/// by practice. -pub(in crate::analytics::tuner) const SHARE_GATE: f64 = 0.8; +/// Share of hits a group needs before it may be searched, per cent, when the search settings do +/// not say: a model that cannot reproduce the fact must not be asked what would have been +/// better. The spec's proposal, to be tuned by practice (`TicksState::gate_pct`). +pub(in crate::analytics::tuner) const DEFAULT_GATE_PCT: u32 = 80; /// Ticks kept in memory across every covered row, for the variants and the search. Past it a /// row is still "covered" — the model ran on it — but its tape is let go and the row sits out @@ -144,8 +144,9 @@ pub(in crate::analytics::tuner) struct TicksData { /// Trades the tuner cannot be run on — container or unresolved kinds, manual exits — in /// the Fact column, not in the table. pub(in crate::analytics::tuner) untunable: usize, - /// Column 0: the whole scope (the same SQL as every axis' "Fact", stamps or not); column - /// 1: the rows fit for the search ([`DealRow::fit`]) — the sample the variants replay. + /// One column: the rows fit for the search ([`DealRow::fit`]) — the sample the variants + /// replay, and the baseline they are compared with. The whole scope is not shown: the axis + /// works on the fit rows only (the developer's call, 2026-09-23). pub(in crate::analytics::tuner) kpi: Vec, /// `(hits, answered)` of the entry group over the covered rows. pub(in crate::analytics::tuner) entry_share: (usize, usize), @@ -195,14 +196,23 @@ impl TicksData { self.rows.iter().filter(|r| r.fit() && r.ticks.is_some()) } - /// The share gate per group: whether the model reproduces enough of the fact to be - /// searched over. `None` when nothing answered yet. - pub(in crate::analytics::tuner) fn group_passes(&self, group: ParamGroup) -> Option { - let (hits, n) = match group { + /// The share gate per group: whether the model reproduces at least `gate` (a fraction) of + /// the fact to be searched over. `None` when nothing answered yet. + pub(in crate::analytics::tuner) fn group_passes( + &self, + group: ParamGroup, + gate: f64, + ) -> Option { + let (hits, n) = self.share_of(group); + (n > 0).then(|| hits as f64 / n as f64 >= gate) + } + + /// `(hits, answered)` of one group over the covered rows. + pub(in crate::analytics::tuner) fn share_of(&self, group: ParamGroup) -> (usize, usize) { + match group { ParamGroup::Entry => self.entry_share, ParamGroup::Exit => self.exit_share, - }; - (n > 0).then(|| hits as f64 / n as f64 >= SHARE_GATE) + } } /// The one kind of the scope, when there is exactly one; the search needs one to know @@ -233,6 +243,16 @@ impl TicksData { } } +/// One of the fetch job's words on a row ([`TicksState::edit_rows`]). +pub(in crate::analytics::tuner) enum RowEdit { + /// A walk went out for the row: a missing row reads "fetching". + MarkFetching, + /// The walk was stopped: a fetching row reads "missing" again. + UnmarkFetching, + /// The row's replay after the walk. + Replay(Box), +} + /// The search of the axis, as far as the row shows it. pub(in crate::analytics::tuner) enum SuggState { Idle, @@ -270,18 +290,26 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) var_task: Option>, /// The grid's and the row's input boxes, created lazily and kept across repaints. pub(in crate::analytics::tuner) inputs: HashMap>, - /// Which groups the search may vary. - pub(in crate::analytics::tuner) vary_entry: bool, - pub(in crate::analytics::tuner) vary_exit: bool, - /// How a MoonShot variant's entry is replayed, by the search and by the variant columns alike. - /// No control sets it yet: the corridor model, as before the choice existed. - pub(in crate::analytics::tuner) entry_method: EntryMethod, - /// Fields held at their base value by the search. + /// Fields held at their base value by the search — the grid's unticked rows. Persisted. pub(in crate::analytics::tuner) locked: HashSet, - /// The search settings, as typed. + /// The field "Search" on one field varies — the one whose name was clicked last. + pub(in crate::analytics::tuner) sel_field: Option<&'static str>, + /// The search settings, as typed; all but the minimum trades persist. pub(in crate::analytics::tuner) iters: String, pub(in crate::analytics::tuner) min_trades: String, pub(in crate::analytics::tuner) train_pct: usize, + /// Base seed of the restarts; empty draws one per search. + pub(in crate::analytics::tuner) seed: String, + /// The seed the last completed search ran with, to pin it. + pub(in crate::analytics::tuner) last_seed: Option, + /// Passes of coordinate descent per restart; empty = the search's default. + pub(in crate::analytics::tuner) passes: String, + /// The group gate, per cent of reproduced trades; empty = [`DEFAULT_GATE_PCT`]. + pub(in crate::analytics::tuner) gate_pct: String, + /// Whether the search settings popover is open. + pub(in crate::analytics::tuner) sugg_cfg_open: bool, + /// Whether the model settings popover is open. + pub(in crate::analytics::tuner) model_cfg_open: bool, pub(in crate::analytics::tuner) sugg: SuggState, pub(in crate::analytics::tuner) sugg_seq: u64, /// What the last completed search found, for the holdout caption. @@ -312,15 +340,20 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) order: Option, /// Bumped whenever `data` changes, so the cached order is rebuilt. pub(in crate::analytics::tuner) rows_rev: u64, - /// Whether the two parameter groups are unfolded. - pub(in crate::analytics::tuner) entry_open: bool, - pub(in crate::analytics::tuner) exit_open: bool, /// The task listening to the process-wide fetch job (`fetch::job`) for this view; `None` /// until a batch is started or found running. Dropped with the view, which ends it. pub(in crate::analytics::tuner) fetch_task: Option>, /// Whether that task is still in its loop — it ends with the batch, and the next batch /// attaches a fresh one. pub(in crate::analytics::tuner) fetch_listening: std::sync::Arc, + /// The model settings every judged row of the table was judged under, when one set is known + /// — what lets a reload carry a row's verdict instead of replaying it (`load.rs`, stage B). + /// `None` until a tape stage has judged the table, and from a change of the settings until + /// the stage that re-judges it has folded. + /// Generation of the tape stage in flight; an older stage's answer is dropped (`load.rs`). + pub(in crate::analytics::tuner) tape_seq: u64, + pub(in crate::analytics::tuner) judged_under: + Option, /// Whether the tape stage of a load is still reading the rows' tape off the worker: until /// it folds, every addressed row reads "missing" without meaning it. pub(in crate::analytics::tuner) tape_reading: bool, @@ -336,13 +369,17 @@ impl Default for TicksState { var_seq: 0, var_task: None, inputs: HashMap::new(), - vary_entry: true, - vary_exit: true, - entry_method: EntryMethod::default(), locked: HashSet::new(), + sel_field: None, iters: String::new(), min_trades: String::new(), train_pct: super::super::filter::state::DEFAULT_TRAIN, + seed: String::new(), + last_seed: None, + passes: String::new(), + gate_pct: String::new(), + sugg_cfg_open: false, + model_cfg_open: false, sugg: SuggState::Idle, sugg_seq: 0, last_result: None, @@ -354,16 +391,58 @@ impl Default for TicksState { only_fit: false, order: None, rows_rev: 0, - entry_open: true, - exit_open: true, fetch_task: None, fetch_listening: Default::default(), tape_reading: false, + judged_under: None, + tape_seq: 0, } } } impl TicksState { + /// The group gate as a fraction: the typed per cent, else [`DEFAULT_GATE_PCT`], within 0–100. + pub(in crate::analytics::tuner) fn gate(&self) -> f64 { + let pct = self + .gate_pct + .trim() + .parse::() + .unwrap_or(DEFAULT_GATE_PCT) + .min(100); + f64::from(pct) / 100.0 + } + + /// Take the persisted settings of the axis (`WindowLayout::analytics_ticks`). + pub(in crate::analytics) fn restore(&mut self, saved: &moon_core::config::TicksAxisLayout) { + self.iters = saved.iters.map(|n| n.to_string()).unwrap_or_default(); + self.train_pct = saved + .train + .map(|n| n as usize) + .filter(|n| super::super::filter::state::TRAIN_OPTIONS.contains(n)) + .unwrap_or(super::super::filter::state::DEFAULT_TRAIN); + self.seed = saved.seed.clone().unwrap_or_default(); + self.passes = saved.passes.map(|n| n.to_string()).unwrap_or_default(); + self.gate_pct = saved.gate_pct.map(|n| n.to_string()).unwrap_or_default(); + self.locked = saved.locked.iter().cloned().collect(); + } + + /// The axis' settings as the layout persists them, the model's from their process-wide + /// store. + pub(in crate::analytics::tuner) fn saved(&self) -> moon_core::config::TicksAxisLayout { + let number = |text: &str| text.trim().parse::().ok(); + let mut locked: Vec = self.locked.iter().cloned().collect(); + locked.sort(); + moon_core::config::TicksAxisLayout { + iters: number(&self.iters), + train: Some(self.train_pct as u32), + seed: Some(self.seed.trim().to_string()).filter(|s| s.parse::().is_ok()), + passes: number(&self.passes), + gate_pct: number(&self.gate_pct), + locked, + model: super::model_cfg::current(), + } + } + /// Report-derived numbers are stale; the next entry into the mode reloads them. pub(in crate::analytics) fn mark_report_stale(&mut self) { self.dirty = true; @@ -442,29 +521,56 @@ impl TicksState { } } - /// Replace one row's replay result in place, after a fetch, keeping the rest. + /// Apply the fetch job's word on some rows, by id, in order — then ONE recount. A hop of the + /// job's listener brings every answer queued since the last one, and a recount per answer + /// is a pass over the whole table each: hundreds of answers served off the disk in a few + /// seconds made that a pass over the table hundreds of times. A row not in the table (the + /// scope moved on) is skipped. /// /// Args: - /// report_uid: The row. - /// update: What the fetch learned. - pub(in crate::analytics::tuner) fn update_row( + /// edits: `(report_uid, what to do)`, in the order the job said it. + pub(in crate::analytics::tuner) fn edit_rows( &mut self, - report_uid: i64, - update: impl FnOnce(&mut DealRow), + edits: impl IntoIterator, ) { let Some(data) = self.data.data_mut() else { return; }; - let Some(row) = data + let index: HashMap = data .rows - .iter_mut() - .find(|r| r.deal.report_uid == report_uid) - else { + .iter() + .enumerate() + .map(|(i, r)| (r.deal.report_uid, i)) + .collect(); + let mut touched = false; + let mut replayed = false; + for (uid, edit) in edits { + let Some(row) = index.get(&uid).and_then(|&i| data.rows.get_mut(i)) else { + continue; + }; + touched = true; + match edit { + RowEdit::MarkFetching if row.tape == TapeStatus::Missing => { + row.tape = TapeStatus::Fetching; + } + RowEdit::UnmarkFetching if row.tape == TapeStatus::Fetching => { + row.tape = TapeStatus::Missing; + } + RowEdit::MarkFetching | RowEdit::UnmarkFetching => {} + RowEdit::Replay(answer) => { + row.take_replay(*answer); + replayed = true; + } + } + } + if !touched { return; - }; - update(row); - data.retain_within_cap(); - data.refresh_summary(); + } + // A mark moves neither the sample nor the shares; only an answer is worth the recount. + if replayed { + data.retain_within_cap(); + data.refresh_summary(); + } let kpi = data.kpi.clone(); // In place as well, so the two load states stay in the same phase: a stale picture // edited during a reload stays stale in both, and a reload's outcome lands on both. @@ -554,18 +660,14 @@ impl TicksData { } } - /// Recompute the fit-subset KPI (column 1) and the ✓ shares from the rows — after a fetch - /// changed one of them. Column 0, the whole scope, comes from the same SQL every axis' - /// "Fact" comes from and is left as loaded. The shares stay over every covered row: they are - /// how much of the tape the model reproduces, which is what the fit subset is cut from. + /// Recompute the fit-subset KPI and the ✓ shares from the rows — after a load or a fetch + /// changed them. The shares stay over every covered row: they are how much of the tape the + /// model reproduces, which is what the fit subset is cut from. pub(in crate::analytics::tuner) fn refresh_summary(&mut self) { let subset = moon_core::db::tuner::ticks::fact_stats( self.rows.iter().filter(|r| r.fit()).map(|r| &r.deal), ); - match self.kpi.get_mut(1) { - Some(slot) => *slot = subset, - None => self.kpi.push(subset), - } + self.kpi = vec![subset]; let verdicts = || self.rows.iter().filter_map(|r| r.verdict.as_ref()); self.entry_share = moon_core::db::tuner::ticks::verify::share(verdicts().map(|v| v.entry)); self.exit_share = moon_core::db::tuner::ticks::verify::share(verdicts().map(|v| v.exit)); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index ad8b7673..8ba82a09 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -21,10 +21,11 @@ use super::super::shared::N_VAR; use super::state::{NowValue, SuggState}; use crate::analytics::bg::ReadLane; use moon_core::db::tuner::threshold_search::SearchHandle; -use moon_core::db::tuner::ticks::mshot::DEFAULT_LATENCY_MS; +use moon_core::db::tuner::ticks::TICK_PARAMS; use moon_core::db::tuner::ticks::params::ParamGroup; use moon_core::db::tuner::ticks::search::{ - PreparedDeal, SearchParams, clip_to_horizon, common_horizon_ms, suggest, variant_tally, + DEFAULT_MAX_PASSES, PreparedDeal, SearchParams, clip_to_horizon, common_horizon_ms, suggest, + variant_tally, }; use moon_core::db::tuner::ticks::stats_of; @@ -43,6 +44,15 @@ pub(super) fn restarts_of(text: &str) -> usize { /// Restarts when the box is empty. pub(super) const DEFAULT_RESTARTS: usize = 20; +/// Passes per restart the search runs with, out of the box's text: the search's default when +/// empty or unreadable, clamped to a sane range. +pub(super) fn passes_of(text: &str) -> usize { + text.trim() + .parse::() + .unwrap_or(DEFAULT_MAX_PASSES) + .clamp(1, 1_000) +} + /// The base every variant is laid over: the fields the selected strategies agree on. fn base_of(now: &HashMap) -> HashMap { now.iter() @@ -117,7 +127,7 @@ impl AnalyticsView { let changes: Vec> = (0..N_VAR).map(|i| self.ticks.variant_changes(i)).collect(); let defaults = self.filter_defaults(cx); - let entry_method = self.ticks.entry_method; + let model = super::model_cfg::current(); let n = deals.len(); self.spawn_latest_db( &[ReadLane::TicksVariants], @@ -130,15 +140,8 @@ impl AnalyticsView { if values.is_empty() || deals.is_empty() { return None; } - let (tally, spent) = variant_tally( - &deals, - &base, - &defaults, - &kind, - values, - DEFAULT_LATENCY_MS, - entry_method, - ); + let (tally, spent) = + variant_tally(&deals, &base, &defaults, &kind, values, model); Some(stats_of(tally, spent)) }) .collect::>() @@ -168,10 +171,16 @@ impl AnalyticsView { self.arm_ticks_variants(cx); } - /// Copy В1 into В2, so a found point can be kept while another is tried. - pub(in crate::analytics::tuner) fn ticks_copy_v1_to_v2(&mut self, cx: &mut Context) { - self.ticks.variants[1] = self.ticks.variants[0].clone(); - self.ticks_reset_inputs_of(1); + /// Copy one variant column over the other — В1 into В2 keeps a found point while another is + /// tried, В2 into В1 brings a kept one back for Save. + pub(in crate::analytics::tuner) fn ticks_copy_variant( + &mut self, + from: usize, + to: usize, + cx: &mut Context, + ) { + self.ticks.variants[to] = self.ticks.variants[from].clone(); + self.ticks_reset_inputs_of(to); self.arm_ticks_variants(cx); cx.notify(); } @@ -195,8 +204,7 @@ impl AnalyticsView { self.ticks.inputs.retain(|id, _| !id.starts_with(&prefix)); } - /// Whether a group may be searched: the user's switch, the kind's support, and the share - /// gate. + /// Whether a group may be searched: the kind's support and the share gate. pub(in crate::analytics::tuner) fn ticks_group_searchable(&self, group: ParamGroup) -> bool { let Some(data) = self.ticks.data.data() else { return false; @@ -205,11 +213,31 @@ impl AnalyticsView { ParamGroup::Entry => data.entry_modelled(), ParamGroup::Exit => true, }; - supported && data.group_passes(group) == Some(true) + supported && data.group_passes(group, self.ticks.gate()) == Some(true) } - /// Run the search into В1. + /// "Search all": every ticked field of the groups the gate lets through, into В1. pub(in crate::analytics::tuner) fn ticks_suggest(&mut self, cx: &mut Context) { + self.ticks_run_search(None, cx); + } + + /// "Search": the selected field alone, the rest of В1 held as it stands; the answer goes + /// into that one cell of В1. + pub(in crate::analytics::tuner) fn ticks_suggest_one(&mut self, cx: &mut Context) { + if let Some(key) = self.ticks.sel_field { + self.ticks_run_search(Some(key), cx); + } + } + + /// Say why a search did not start. + fn ticks_search_refused(&mut self, key: &str, cx: &mut Context) { + self.ticks.sugg_note = Some(t!(key).to_string()); + cx.notify(); + } + + /// Run the search into В1: over every ticked field (`only` = `None`), or over one field with + /// every other held — at the strategies' value, or at В1's where В1 changes it. + fn ticks_run_search(&mut self, only: Option<&'static str>, cx: &mut Context) { if matches!(self.ticks.sugg, SuggState::Running { .. }) { return; } @@ -218,26 +246,54 @@ impl AnalyticsView { return; }; let Some(kind) = data.single_kind().map(String::from) else { - self.ticks.sugg_note = Some(t!("analytics.ticks.sugg_one_kind").to_string()); - cx.notify(); - return; + return self.ticks_search_refused("analytics.ticks.sugg_one_kind", cx); + }; + let model = super::model_cfg::current(); + let mut base = base_of(&data.now); + let (vary_entry, vary_exit, locked) = match only { + None => ( + self.ticks_group_searchable(ParamGroup::Entry), + self.ticks_group_searchable(ParamGroup::Exit), + self.ticks.locked.clone(), + ), + Some(key) => { + let Some(field) = TICK_PARAMS.iter().find(|f| f.key == key) else { + return; + }; + if !model.entry_method.reads(key) { + return self.ticks_search_refused("analytics.ticks.sugg_not_read", cx); + } + if !self.ticks_group_searchable(field.group) { + return self.ticks_search_refused("analytics.ticks.sugg_gated", cx); + } + // The other fields as В1 has them: the one field is searched in the variant it + // will land in, not in the strategy as it stands. + for (k, v) in self.ticks.variant_changes(0) { + base.insert(k, v); + } + let locked: HashSet = TICK_PARAMS + .iter() + .map(|f| f.key) + .filter(|k| *k != key) + .map(str::to_string) + .collect(); + ( + field.group == ParamGroup::Entry, + field.group == ParamGroup::Exit, + locked, + ) + } }; - let vary_entry = self.ticks.vary_entry && self.ticks_group_searchable(ParamGroup::Entry); - let vary_exit = self.ticks.vary_exit && self.ticks_group_searchable(ParamGroup::Exit); if !(vary_entry || vary_exit) { - self.ticks.sugg_note = Some(t!("analytics.ticks.sugg_nothing").to_string()); - cx.notify(); - return; + return self.ticks_search_refused("analytics.ticks.sugg_nothing", cx); } if deals.is_empty() { - self.ticks.sugg_note = Some(t!("analytics.ticks.sugg_no_tape").to_string()); - cx.notify(); - return; + return self.ticks_search_refused("analytics.ticks.sugg_no_tape", cx); } - let base = base_of(&data.now); let defaults = self.filter_defaults(cx); - let locked: HashSet = self.ticks.locked.clone(); let restarts = restarts_of(&self.ticks.iters); + let max_passes = passes_of(&self.ticks.passes); + let seed = self.ticks.seed.trim().parse::().ok(); let min_n = self .ticks .min_trades @@ -246,7 +302,6 @@ impl AnalyticsView { .ok() .filter(|n| *n > 0); let train_frac = super::super::filter::state::train_frac(self.ticks.train_pct); - let entry_method = self.ticks.entry_method; let handle = SearchHandle::new(); self.ticks.sugg = SuggState::Running { handle: handle.clone(), @@ -269,10 +324,10 @@ impl AnalyticsView { locked: &locked, restarts, min_n, - seed: None, + seed, train_frac, - latency_ms: DEFAULT_LATENCY_MS, - entry_method, + max_passes, + model, }; suggest(&deals, ¶ms, &handle) }, @@ -283,9 +338,23 @@ impl AnalyticsView { this.ticks.sugg = SuggState::Idle; match result { Some(result) => { - this.ticks.variants[0] = - result.values.iter().cloned().collect::>(); + match only { + None => { + this.ticks.variants[0] = + result.values.iter().cloned().collect::>(); + } + // Only the searched cell moves; one the search left at its base + // keeps what В1 had. + Some(key) => { + if let Some((_, value)) = + result.values.iter().find(|(k, _)| k == key) + { + this.ticks.set_variant(0, key, value.clone()); + } + } + } this.ticks_reset_inputs_of(0); + this.ticks.last_seed = Some(result.seed); this.ticks.last_result = Some(result); this.arm_ticks_variants(cx); } diff --git a/crates/moon-ui-gpui/tests/theme_contract/theme.rs b/crates/moon-ui-gpui/tests/theme_contract/theme.rs index 79ccca56..8dccf470 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/theme.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/theme.rs @@ -255,6 +255,13 @@ fn popover_contents_do_not_paint_a_second_surface() { "analytics/tuner/shell.rs", r#".id("tun-cfg-popup")"#, ), + // The Entry/Exit axis' two popovers — the search settings and the model settings — share + // one content root. + ( + "analytics/tuner/ticks/cfg.rs", + "analytics/tuner/ticks/cfg.rs", + "fn popup_frame(", + ), ( "controls/metric.rs", "controls/metric.rs", diff --git a/locales/analytics.yml b/locales/analytics.yml index 2b97aa2f..81a89248 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1853,9 +1853,9 @@ analytics.ticks.model_tip: en: "Entry: model vs fact %{entry} · Exit: %{exit}" es: "Entrada: modelo vs hecho %{entry} · Salida: %{exit}" analytics.ticks.subset: - ru: "Факт · годные" - en: "Fact · reproduced" - es: "Hecho · reproducidas" + ru: "Годные" + en: "Reproduced" + es: "Reproducidas" analytics.ticks.subset_sub: ru: "по %{n} из %{m} с лентой · вход ✓ %{entry} · выход ✓ %{exit}" en: "%{n} of %{m} with tape · entry ✓ %{entry} · exit ✓ %{exit}" @@ -1868,14 +1868,10 @@ analytics.ticks.params_title: ru: "Параметры" en: "Parameters" es: "Parámetros" -analytics.ticks.params_sub: - ru: "сейчас · варианты · подбор" - en: "now · variants · search" - es: "ahora · variantes · búsqueda" analytics.ticks.assumptions: - ru: "Модель не учитывает: стакан и очередь · выход кроме тейка · дельты как константы окна · опора ASK/BID = последний принт стороны · MShotRepeat* · задержка перестановки 100 мс" - en: "Not modelled: the book and the queue · exits other than the take · deltas constant over the window · ASK/BID reference = the last print of that side · MShotRepeat* · a 100 ms replacement latency" - es: "No modelado: libro y cola · salidas salvo el take · deltas constantes en la ventana · referencia ASK/BID = último print del lado · MShotRepeat* · latencia de reemplazo de 100 ms" + ru: "Модель не знает: стакан и очередь на уровне · правила выхода вне модели (трейлинг, лестница стопов) · MShotRepeat* · опора ASK/BID — последний принт своей стороны" + en: "The model does not know: the book and the queue at a level · exit rules outside it (trailing, the stop ladder) · MShotRepeat* · the ASK/BID reference is the last print of its side" + es: "El modelo no conoce: el libro y la cola en un nivel · reglas de salida fuera de él (trailing, escalera de stops) · MShotRepeat* · la referencia ASK/BID es el último print de su lado" analytics.ticks.group_entry: ru: "Вход" en: "Entry" @@ -1888,18 +1884,6 @@ analytics.ticks.entry_from_fact: ru: "вход для %{kinds} не моделируется — берётся из факта" en: "the entry of %{kinds} is not modelled — taken from the fact" es: "la entrada de %{kinds} no se modela — se toma del hecho" -analytics.ticks.group_collapse: - ru: "Свернуть группу" - en: "Collapse the group" - es: "Plegar el grupo" -analytics.ticks.group_expand: - ru: "Развернуть группу" - en: "Expand the group" - es: "Desplegar el grupo" -analytics.ticks.now: - ru: "сейчас" - en: "now" - es: "ahora" analytics.ticks.no_deals: ru: "в выборке нет сделок с мс-штампами" en: "no trades with millisecond stamps in the scope" @@ -1916,18 +1900,6 @@ analytics.ticks.holdout: ru: "holdout: %{n} сделок, %{profit}" en: "holdout: %{n} trades, %{profit}" es: "holdout: %{n} operaciones, %{profit}" -analytics.ticks.fix: - ru: "фикс." - en: "fix" - es: "fijo" -analytics.ticks.vary: - ru: "перебирать" - en: "vary" - es: "variar" -analytics.ticks.vary_tip: - ru: "Подбор перебирает поля этой группы (кроме фиксированных)" - en: "The search varies this group's fields (except the fixed ones)" - es: "La búsqueda varía los campos de este grupo (salvo los fijados)" analytics.ticks.vary_gated: ru: "Модель воспроизводит факт лишь в %{hits} из %{n} сделок — меньше %{gate} %, подбирать по ней нельзя" en: "The model reproduces the fact on only %{hits} of %{n} trades — under %{gate} %, so it cannot be searched over" @@ -1936,14 +1908,6 @@ analytics.ticks.vary_unknown: ru: "Доля воспроизведения ещё не посчитана — нет сделок с лентой" en: "The reproduction share is not known yet — no trades with tape" es: "La cuota de reproducción aún no se conoce — no hay operaciones con cinta" -analytics.ticks.v1_to_v2: - ru: "В1 → В2" - en: "V1 → V2" - es: "V1 → V2" -analytics.ticks.clear_v: - ru: "очистить В%{n}" - en: "clear V%{n}" - es: "limpiar V%{n}" analytics.ticks.sugg_one_kind: ru: "Подбор — только по стратегиям одного вида" en: "The search needs strategies of one kind" @@ -1964,3 +1928,211 @@ analytics.ticks.closer_warn: ru: "MShotPrice ближе фактического: оценка занижена — прострелы, до которых реальный ордер не дотянулся, в отчёте отсутствуют" en: "MShotPrice closer than the fact: underestimated — spikes the real order never reached are not in the report" es: "MShotPrice más cerca que el hecho: subestimado — los picos que la orden real nunca alcanzó no están en el informe" +analytics.ticks.var_untouched: + ru: "без изменений" + en: "unchanged" + es: "sin cambios" +analytics.ticks.not_read: + ru: "не читается" + en: "not read" + es: "no se lee" +analytics.ticks.suggest_one_tip: + ru: "Подобрать только %{field}; остальные поля — как в В1" + en: "Search %{field} alone; the other fields as V1 has them" + es: "Buscar solo %{field}; los demás campos como en V1" +analytics.ticks.suggest_one_none: + ru: "Выберите поле: щёлкните по его имени в таблице" + en: "Pick a field: click its name in the grid" + es: "Elija un campo: haga clic en su nombre en la tabla" +analytics.ticks.sugg_not_read: + ru: "Выбранный способ входа это поле не читает — подбирать нечего" + en: "The entry method in force does not read this field — nothing to search" + es: "El método de entrada activo no lee este campo — nada que buscar" +analytics.ticks.sugg_gated: + ru: "Группа этого поля не проходит порог воспроизводимости — подбор по ней закрыт" + en: "This field's group is under the reproduction gate — it cannot be searched" + es: "El grupo de este campo está bajo el umbral de reproducción — no se puede buscar" +analytics.ticks.cfg_passes: + ru: "Проходов на перезапуск" + en: "Passes per restart" + es: "Pasadas por reinicio" +analytics.ticks.cfg_passes_tip: + ru: "Сколько раз спуск обходит все поля, прежде чем перезапуск считается сошедшимся" + en: "How many times the descent visits every field before a restart counts as converged" + es: "Cuántas veces el descenso recorre todos los campos antes de dar un reinicio por convergido" +analytics.ticks.cfg_gate: + ru: "Порог группы, %" + en: "Group gate, %" + es: "Umbral del grupo, %" +analytics.ticks.cfg_gate_tip: + ru: "Какая доля сделок должна воспроизводиться моделью, чтобы группу (вход или выход) можно было подбирать" + en: "The share of trades the model must reproduce before a group (entry or exit) may be searched" + es: "La cuota de operaciones que el modelo debe reproducir para que un grupo (entrada o salida) se pueda buscar" +analytics.ticks.cfg_entry_section: + ru: "Вход варианта" + en: "A variant's entry" + es: "Entrada de una variante" +analytics.ticks.cfg_method: + ru: "Способ" + en: "Method" + es: "Método" +analytics.ticks.method_model: + ru: "модель коридора" + en: "corridor model" + es: "modelo del corredor" +analytics.ticks.method_model_help: + ru: "Весь путь ордера варианта от создания: читает все поля входа. Галки факта считаются так же." + en: "The variant's whole order path from its creation: reads every entry field. The fact's ✓ is judged this way too." + es: "Todo el camino de la orden de la variante desde su creación: lee todos los campos de entrada. El ✓ del hecho se juzga así también." +analytics.ticks.method_shift: + ru: "сдвиг факта" + en: "shift of the fact" + es: "desplazamiento del hecho" +analytics.ticks.method_shift_help: + ru: "Ордер факта у сопли, сдвинутый на глубину варианта: MShotRaiseWait, MShotReplaceDelay, MShotUsePrice и FastShotAlgo не читает." + en: "The fact's order at the spike, moved by the variant's depth: does not read MShotRaiseWait, MShotReplaceDelay, MShotUsePrice or FastShotAlgo." + es: "La orden del hecho en el pico, desplazada por la profundidad de la variante: no lee MShotRaiseWait, MShotReplaceDelay, MShotUsePrice ni FastShotAlgo." +analytics.ticks.model_btn: + ru: "Модель" + en: "Model" + es: "Modelo" +analytics.ticks.model_title: + ru: "Настройки модели" + en: "Model settings" + es: "Ajustes del modelo" +analytics.ticks.model_reset: + ru: "По умолчанию" + en: "Defaults" + es: "Por defecto" +analytics.ticks.model_sec_entry: + ru: "Вход" + en: "Entry" + es: "Entrada" +analytics.ticks.model_sec_take: + ru: "Тейк" + en: "Take" + es: "Take" +analytics.ticks.model_sec_stop: + ru: "Стоп по стакану" + en: "Book-watching stop" + es: "Stop sobre el libro" +analytics.ticks.model_sec_line: + ru: "Линия выхода" + en: "Exit line" + es: "Línea de salida" +analytics.ticks.model_sec_verdict: + ru: "Сверка с фактом" + en: "Judging the fact" + es: "Comparación con el hecho" +analytics.ticks.model_latency: + ru: "Задержка перестановки, мс" + en: "Replace latency, ms" + es: "Latencia de reemplazo, ms" +analytics.ticks.model_latency_tip: + ru: "Сколько перестановка ордера (входа и продажи) идёт до биржи; принт в это время бьёт по старому уровню" + en: "How long a re-place (of the entry or of the sell) takes to reach the exchange; a print in between meets the old level" + es: "Cuánto tarda un reemplazo (de la entrada o de la venta) en llegar al exchange; un print entretanto encuentra el nivel viejo" +analytics.ticks.model_replace_window: + ru: "Окно цены перестановки, мс" + en: "Re-place price window, ms" + es: "Ventana de precio del reemplazo, ms" +analytics.ticks.model_replace_window_tip: + ru: "Новый ордер ставится от крайнего принта за это окно (ядро: минимум трейдов за ~75–150 мс)" + en: "A re-placed order goes off the extreme print of this window (the core: the minimum trade over ~75–150 ms)" + es: "Una orden reemplazada se coloca desde el print extremo de esta ventana (el núcleo: el mínimo de ~75–150 ms)" +analytics.ticks.model_shift_window: + ru: "Окно сдвига, мс" + en: "Shift window, ms" + es: "Ventana del desplazamiento, ms" +analytics.ticks.model_shift_window_tip: + ru: "Способ «сдвиг»: сколько после налива факта та же сопля ещё может налить сдвинутый ордер" + en: "The shift method: how long past the fact's fill the same spike may still fill the shifted order" + es: "Método desplazamiento: cuánto tras el llenado del hecho el mismo pico aún puede llenar la orden desplazada" +analytics.ticks.model_pre_spike: + ru: "Цена до сопли, мс" + en: "Pre-spike price, ms" + es: "Precio antes del pico, ms" +analytics.ticks.model_pre_spike_tip: + ru: "MShotSellAtLastPrice без архива: последний принт не позже этого до налива" + en: "MShotSellAtLastPrice without the archive: the last print at least this long before the fill" + es: "MShotSellAtLastPrice sin archivo: el último print al menos este tiempo antes del llenado" +analytics.ticks.model_ticker: + ru: "Период тикера, мс" + en: "Ticker period, ms" + es: "Periodo del ticker, ms" +analytics.ticks.model_ticker_tip: + ru: "Как часто ядро получает BID тикера, который смотрит стоп без FastStopLoss (ядро: ~2–2,3 с)" + en: "How often the core gets the ticker BID a stop without FastStopLoss watches (the core: ~2–2.3 s)" + es: "Cada cuánto recibe el núcleo el BID del ticker que vigila un stop sin FastStopLoss (el núcleo: ~2–2,3 s)" +analytics.ticks.model_series: + ru: "Шаг ряда цен, мс" + en: "Price series tick, ms" + es: "Paso de la serie de precios, ms" +analytics.ticks.model_series_tip: + ru: "Шаг ряда цен ядра, по которому ещё срабатывает стоп при StopLossEMA 0" + en: "The core's price-series tick a stop at StopLossEMA 0 also fires on" + es: "El paso de la serie de precios del núcleo con el que también salta un stop con StopLossEMA 0" +analytics.ticks.model_step_floor: + ru: "Мин. шаг линии, мс" + en: "Line step floor, ms" + es: "Paso mínimo de la línea, ms" +analytics.ticks.model_step_floor_tip: + ru: "Задержка шага PriceDown / SellLevel при нуле в стратегии (FAQ: 0,33 с)" + en: "A PriceDown / SellLevel step delay set to zero in the strategy (FAQ: 0.33 s)" + es: "Retardo de paso PriceDown / SellLevel en cero en la estrategia (FAQ: 0,33 s)" +analytics.ticks.model_pump_lag: + ru: "Pump: запаздывание шага, мс" + en: "Pump: move lag, ms" + es: "Pump: retraso del movimiento, ms" +analytics.ticks.model_pump_lag_tip: + ru: "На сколько после PumpMoveTimer ядро переставляет продажу PumpsDetection" + en: "How far past PumpMoveTimer the core moves a PumpsDetection sell" + es: "Cuánto después de PumpMoveTimer el núcleo mueve la venta de PumpsDetection" +analytics.ticks.model_pump_peak: + ru: "Pump: окно пика, мс" + en: "Pump: peak window, ms" + es: "Pump: ventana del pico, ms" +analytics.ticks.model_pump_peak_tip: + ru: "Как далеко до тейка ищется пик пампа" + en: "How far before the take the pump's peak is looked for" + es: "Cuánto antes del take se busca el pico del bombeo" +analytics.ticks.model_point_time: + ru: "Допуск по времени, мс" + en: "Time tolerance, ms" + es: "Tolerancia de tiempo, ms" +analytics.ticks.model_point_time_tip: + ru: "Насколько перестановка модели может разойтись во времени с архивной и считаться той же" + en: "How far apart in time a modelled and an archived move may be and still count as one" + es: "Cuánto pueden separarse en el tiempo un movimiento modelado y uno archivado y seguir contando como uno" +analytics.ticks.model_book_stop_time: + ru: "Допуск стопа по стакану, мс" + en: "Book stop time tolerance, ms" + es: "Tolerancia del stop sobre el libro, ms" +analytics.ticks.model_book_stop_time_tip: + ru: "Насколько стоп без FastStopLoss может сработать раньше или позже факта" + en: "How much earlier or later than the fact a stop without FastStopLoss may fire" + es: "Cuánto antes o después del hecho puede saltar un stop sin FastStopLoss" +analytics.ticks.model_price: + ru: "Допуск цены, %" + en: "Price tolerance, %" + es: "Tolerancia de precio, %" +analytics.ticks.model_price_tip: + ru: "Насколько цена модели может отойти от факта (и нижняя граница допуска входа)" + en: "How far a modelled price may sit from the fact (and the entry tolerance's floor)" + es: "Cuánto puede alejarse un precio modelado del hecho (y el mínimo de la tolerancia de entrada)" +analytics.ticks.model_stop_price: + ru: "Допуск уровня стопа, %" + en: "Stop level tolerance, %" + es: "Tolerancia del nivel de stop, %" +analytics.ticks.model_stop_price_tip: + ru: "Насколько уровень стопа модели может отойти от уровня, записанного ядром" + en: "How far the modelled stop level may sit from the one the core recorded" + es: "Cuánto puede alejarse el nivel de stop modelado del que registró el núcleo" +analytics.ticks.model_fill_better: + ru: "Допуск улучшения налива, %" + en: "Fill improvement tolerance, %" + es: "Tolerancia de mejora del llenado, %" +analytics.ticks.model_fill_better_tip: + ru: "Насколько лучше уровня модели может налиться лимитка факта" + en: "How much better than the modelled level the fact's limit may fill" + es: "Cuánto mejor que el nivel modelado puede llenarse el límite del hecho" From 3d8a5941e3cd84b622c7a30515f4653d5f5d43f5 Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 20:21:03 +0200 Subject: [PATCH 28/51] test(trade-replay): move main's Gate trade tests onto the time/id futures cursor --- .../market/trade_replay/rest/gateio/tests.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs b/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs index dce46b68..b28d000c 100644 --- a/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs +++ b/crates/moon-core/src/market/trade_replay/rest/gateio/tests.rs @@ -253,17 +253,22 @@ fn gate_futures_trade_falls_back_to_second_timestamps() { /// the next page to a row other than the oldest one it holds, either stops a silently /// truncated page or skips the prints between the two rows. #[test] -fn gate_futures_trade_full_page_continues_by_row_count() { - let row = serde_json::json!({"price": "3", "size": 1, "create_time_ms": 10.0}); - let one = serde_json::json!([row]); - let exact = parse_futures_trades(&one, 1, Some(TradeCursor::Offset(10))).expect("exact page"); - assert_eq!(exact.next, Some(TradeCursor::Offset(11))); +fn gate_futures_trade_full_page_continues_from_its_oldest_row() { + let one = serde_json::json!([{"id": 7, "price": "3", "size": 1, "create_time_ms": 10.5}]); + let exact = parse_futures_trades(&one, 1, None).expect("exact page"); + assert_eq!( + exact.next, + Some(TradeCursor::Before { + boundary_ms: 10_500, + below_id: 7 + }) + ); assert_eq!(exact.ticks.len(), 1); // Newest first, as the venue answers. let two = serde_json::json!([ - {"price": "3", "size": 1, "create_time_ms": 10.0}, - {"price": "4", "size": 2, "create_time_ms": 11.0} + {"id": 8, "price": "4", "size": 2, "create_time_ms": 11.0}, + {"id": 7, "price": "3", "size": 1, "create_time_ms": 10.0} ]); let over = parse_futures_trades(&two, 1, None).expect("over-full"); assert_eq!( From e4cefec2717165b870b8736e179ae63f2c158e4e Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 21:40:27 +0200 Subject: [PATCH 29/51] feat(tuner): show the selected deal under the Entry/Exit table with the 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. --- crates/moon-core/src/config/layout.rs | 2 + crates/moon-core/src/config/layout/tests.rs | 3 + crates/moon-core/src/db/tuner/ticks/mod.rs | 2 +- crates/moon-core/src/db/tuner/ticks/mshot.rs | 43 +++ crates/moon-core/src/db/tuner/ticks/search.rs | 118 ++++++- .../src/db/tuner/ticks/search/tests.rs | 39 +++ .../src/session/order_lines/archived/tests.rs | 104 ------ crates/moon-ui-gpui/src/analytics/mod.rs | 11 +- crates/moon-ui-gpui/src/analytics/render.rs | 9 +- .../moon-ui-gpui/src/analytics/tuner/mod.rs | 12 +- .../src/analytics/tuner/ticks/columns.rs | 27 +- .../src/analytics/tuner/ticks/mod.rs | 147 +++++++-- .../src/analytics/tuner/ticks/rows.rs | 29 +- .../src/analytics/tuner/ticks/state.rs | 16 +- .../src/analytics/tuner/ticks/trade_pane.rs | 304 ++++++++++++++++++ .../src/analytics/tuner/ticks/variants.rs | 48 ++- crates/moon-ui-gpui/src/trade_window/mod.rs | 10 +- .../src/trade_window/open_record.rs | 52 ++- .../moon-ui-gpui/src/trade_window/window.rs | 4 - .../tests/theme_contract/windowing.rs | 4 +- locales/analytics.yml | 36 +++ 21 files changed, 845 insertions(+), 175 deletions(-) create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index 37259ed7..55946eb8 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -645,6 +645,8 @@ pub struct TicksAxisLayout { pub locked: Vec, /// The model's own settings. pub model: crate::db::tuner::ticks::ModelSettings, + /// Whether the trade pane under the deal table is open. + pub trade_open: bool, } /// Complete window layout. diff --git a/crates/moon-core/src/config/layout/tests.rs b/crates/moon-core/src/config/layout/tests.rs index f979d5a4..d0caa355 100644 --- a/crates/moon-core/src/config/layout/tests.rs +++ b/crates/moon-core/src/config/layout/tests.rs @@ -2211,6 +2211,8 @@ fn trade_window_fit_and_hide_rail_default_off_and_round_trip() { assert_eq!(off.trade_window_ticks, Some(false)); } +/// The corridor switch reads absent as OFF, the tuner pane's rail reads absent as HIDDEN, and +/// both survive a round trip without touching the window's own rail switch. #[test] fn the_corridor_and_the_tuner_panes_rail_read_their_own_defaults() { let old: WindowLayout = toml::from_str("").expect("legacy layout"); @@ -2325,6 +2327,7 @@ fn the_ticks_axis_settings_round_trip_and_never_cost_the_layout() { analytics_ticks: Some(TicksAxisLayout { iters: Some(40), locked: vec!["SellPrice".to_string()], + trade_open: true, model: crate::db::tuner::ticks::ModelSettings { latency_ms: 250.0, entry_method: crate::db::tuner::ticks::EntryMethod::Shift, diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 5a46fcfc..c8833052 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -45,7 +45,7 @@ pub use deals::{DealsRead, read_deals}; pub use entry::{EntryModel, entry_model_for}; pub use exit::{ExitModel, ExitParams, archived_pre_spike_ask, archived_take, take_model_for}; pub use hook::{HookDetect, KIND_MOONHOOK, hook_take_pct, parse_hook_detect}; -pub use mshot::{EntryMethod, MshotEntry, MshotParams, UsePrice}; +pub use mshot::{CorridorStep, EntryMethod, MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; pub use record::{OwnLines, StopAnchor, entry_placement, fit_for_search, prepare_deal}; pub use scope::{is_service_row, is_tunable}; diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index 67b7020b..32c0db4f 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -304,6 +304,21 @@ impl MshotParams { } } +/// One placement of a modelled order and the corridor around it, until the next placement. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CorridorStep { + /// When the core placed or re-placed the order, Unix ms. + pub t_ms: i64, + /// The order's level. + pub level: f64, + /// The corridor as the core saves it — a band of ORDER prices off the reference the level + /// stood from: `R·(1 − near)` … `R·(1 − retreat)` for a long, mirrored for a short, with + /// `retreat` = `2 · far − near` where `near ≥ far / 2` ([`retreat_pct`]). The same band the + /// report's `BuyCorridorDown` / `BuyCorridorUp` hold for the fact, in the near-edge, + /// retreat-edge order. + pub band: (f64, f64), +} + /// Which way the price left the corridor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Breach { @@ -517,6 +532,34 @@ impl<'a> MshotEntry<'a> { (fill, moves) } + /// [`Self::trace`], each move with the corridor it stood in — what the tuner's trade pane + /// draws as a variant's corridor. A move whose reference cannot be read back from its level + /// is left out. + pub fn corridor( + &self, + deal: &Deal, + ticks: &[Tick], + line: Option<&[(i64, f64)]>, + ) -> (Option, Vec) { + let (fill, moves) = self.trace(deal, ticks, line); + let mut bounds = LiveBounds::new(self.params, deal); + let sign = if deal.is_long() { -1.0 } else { 1.0 }; + let steps = moves + .into_iter() + .filter_map(|(t_ms, level)| { + let (near_pct, far_pct) = bounds.at(t_ms); + let reference = Self::reference_of(level, far_pct, deal)?; + let edge = |pct: f64| reference * (1.0 + sign * pct / 100.0); + Some(CorridorStep { + t_ms, + level, + band: (edge(near_pct), edge(retreat_pct(near_pct, far_pct))), + }) + }) + .collect(); + (fill, steps) + } + /// Args (beyond [`Self::run`]'s): /// moves: Where to record every move, when asked. fn run_traced( diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index 4347bbfc..25004346 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -28,11 +28,12 @@ use std::sync::Arc; use rayon::prelude::*; +use super::mshot::{CorridorStep, EntryMethod, MshotEntry}; use super::params::{ ParamGroup, ParamKind, StrategyValues, TICK_PARAMS, exit_params, mshot_params, }; use super::settings::ModelSettings; -use super::{Deal, EntryParams, ExitParams, entry_model_for, simulate}; +use super::{Deal, EntryParams, ExitParams, Outcome, entry_model_for, simulate}; use crate::db::metrics::Tally; use crate::db::tuner::threshold_search::search::{install, restart_seed}; use crate::db::tuner::threshold_search::{SearchHandle, train_split}; @@ -403,14 +404,125 @@ pub fn variant_tally( values: &[(String, String)], model: ModelSettings, ) -> (Tally, f64) { + let (entry, exit) = variant_params(base, defaults, kind, values, model); + install(|| tally_and_spent(deals, &entry, &exit)) +} + +/// One variant on one deal, as the tuner's trade pane draws it. +#[derive(Clone, Debug, PartialEq)] +pub struct VariantPicture { + /// Where the entry filled and the exit closed. + pub outcome: Outcome, + /// The order's corridor as the model walked it, placement by placement — a MoonShot variant + /// replayed by the corridor model only; empty for a shift and for a kind without an entry + /// model, which walk no corridor of their own. + pub corridor: Vec, +} + +/// One variant replayed on ONE deal — what the tuner's trade pane draws beside the fact: where +/// the variant's entry filled and where its exit closed, by the same parameters and the same +/// replay [`variant_tally`] scores the column with, and the corridor its order walked. +/// +/// Args: +/// deal: The deal with its tape, cut at the sample's horizon as the column's are. +/// base, defaults, kind, values, model: As for [`variant_tally`]. +/// +/// Returns: +/// The modelled outcome and corridor. +pub fn variant_picture( + deal: &PreparedDeal, + base: &HashMap, + defaults: &HashMap, + kind: &str, + values: &[(String, String)], + model: ModelSettings, +) -> VariantPicture { + let (entry, exit) = variant_params(base, defaults, kind, values, model); + let line = deal.entry_line.as_deref(); + let outcome = simulate(&deal.deal, &deal.ticks, &entry, &exit, line); + // A variant that keeps the trade's own entry fills where the report says (`simulate`), and + // the fact's own line is already on the chart: a modelled path beside it would end somewhere + // else than the fill it is drawn with. + let own = |params: &super::MshotParams| { + matches!( + deal.deal.own_entry.as_ref(), + Some(EntryParams::MoonShot(own)) if own.same_strategy(params) + ) + }; + let corridor = match &entry { + EntryParams::MoonShot(params) + if params.model.entry_method == EntryMethod::Model && !own(params) => + { + MshotEntry::new(params) + .corridor(&deal.deal, &deal.ticks, line) + .1 + } + _ => Vec::new(), + }; + VariantPicture { outcome, corridor } +} + +/// Each deal's `(report_uid, (money, per cent))` under one variant, in the deals' order; `None` +/// where the variant makes no trade of the deal. +pub type DealResults = Vec<(i64, Option<(f64, f64)>)>; + +/// Every deal's result under one variant — `(money, per cent)`, money in the sample's unit — +/// what the deal table's plan column shows. `None` where the variant makes no trade of the deal +/// (no fill, or still open where the tape ends): the same rule that leaves the deal out of +/// [`variant_tally`]. +/// +/// Args: +/// deals, base, defaults, kind, values, model: As for [`variant_tally`]. +/// +/// Returns: +/// The tally, the spent sum, and each deal's result ([`DealResults`]). +pub fn variant_tally_by_deal( + deals: &[PreparedDeal], + base: &HashMap, + defaults: &HashMap, + kind: &str, + values: &[(String, String)], + model: ModelSettings, +) -> (Tally, f64, DealResults) { + let (entry, exit) = variant_params(base, defaults, kind, values, model); + install(|| { + let money: DealResults = deals + .par_iter() + .map(|d| { + let outcome = simulate(&d.deal, &d.ticks, &entry, &exit, d.entry_line.as_deref()); + let result = outcome.profit_money(&d.deal).zip(outcome.profit_pct); + (d.deal.report_uid, result) + }) + .collect(); + let mut tally = Tally::default(); + let mut spent = 0.0; + for (deal, (_, value)) in deals.iter().zip(&money) { + if let Some((value, _)) = value { + tally.push(*value); + spent += deal.deal.spent; + } + } + (tally, spent, money) + }) +} + +/// The entry and exit parameters of a variant's changes laid over the base — the one reading +/// [`variant_tally`], [`variant_tally_by_deal`] and [`variant_picture`] take, so a column, its +/// per-deal share and a picture cannot differ. +fn variant_params( + base: &HashMap, + defaults: &HashMap, + kind: &str, + values: &[(String, String)], + model: ModelSettings, +) -> (EntryParams, ExitParams) { let mut point = Point::new(); for (key, value) in values { if let Some(field) = TICK_PARAMS.iter().find(|f| f.key == key) { point.insert(field.key, value.clone()); } } - let (entry, exit) = params_of(base, defaults, &point, kind, model.sanitized()); - install(|| tally_and_spent(deals, &entry, &exit)) + params_of(base, defaults, &point, kind, model.sanitized()) } #[cfg(test)] diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 2f60f1f7..a3e5421b 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -138,6 +138,45 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { ); assert!((tally.profit - 80.0).abs() < 1e-6); assert!((spent - 8_000.0).abs() < 1e-6); + // And one deal of it as the trade pane draws it: the same parameters, the same replay — the + // entry at the fact, the take at 1 % on the peak. + let picture = variant_picture( + &deals[0], + &base, + &defaults, + "PumpsDetection", + &result.values, + ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + ); + assert!(picture.corridor.is_empty(), "no entry model, no corridor"); + let outcome = picture.outcome; + assert_eq!(outcome.fill.map(|f| f.t_ms), Some(deals[0].deal.buy_ms)); + let exit = outcome.exit.expect("an exit"); + assert_eq!(exit.kind, crate::db::tuner::ticks::ExitKind::Take); + assert!((exit.price - 101.0).abs() < 1e-6, "{exit:?}"); + assert!((outcome.profit_pct.expect("a trade") - 1.0).abs() < 1e-6); + // And the deal table's plan column: every deal's money, summing to the column's tally. + let (by_tally, by_spent, money) = variant_tally_by_deal( + &deals, + &base, + &defaults, + "PumpsDetection", + &result.values, + ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + ); + assert_eq!(by_tally.n, tally.n); + assert!((by_tally.profit - tally.profit).abs() < 1e-9); + assert!((by_spent - spent).abs() < 1e-9); + assert_eq!(money.len(), 8); + assert!(money.iter().all(|(_, m)| { + m.is_some_and(|(money, pct)| (money - 10.0).abs() < 1e-6 && (pct - 1.0).abs() < 1e-6) + })); } #[test] diff --git a/crates/moon-core/src/session/order_lines/archived/tests.rs b/crates/moon-core/src/session/order_lines/archived/tests.rs index 17232762..0b67cf05 100644 --- a/crates/moon-core/src/session/order_lines/archived/tests.rs +++ b/crates/moon-core/src/session/order_lines/archived/tests.rs @@ -380,107 +380,3 @@ fn an_archived_own_entry_line_replaces_the_reports_and_neighbours_fall_back() { vec![(700.0, 1.0)] ); } - -/// Archived points as the store draws them: the price narrowed to the chart's `f32`. -fn drawn_points(points: &[(f64, f64)]) -> Vec<(f64, f32)> { - points.iter().map(|&(t, p)| (t, p as f32)).collect() -} - -/// A stop sold by market leaves no price on the sell order: the core's trace stops at the take -/// it last stood at, and the report alone says where the position closed. The exit line stands at -/// the report's sell price, and the trace is carried to it at the close — the trade window and a -/// "Moonbot lines" chart alike. -#[test] -fn an_exit_trace_that_ends_off_the_sell_price_is_carried_to_it_at_the_close() { - let exit = ReportExit { - price: 1.2, - set_ms: 2_000.0, - }; - // An anchor and one move, as the core writes them: held at 1.6 to 2_500, down to 1.55. - let taken = [ - (2_000.0, 1.6), - (2_500.0, 1.6), - (2_450.0, 1.55), - (2_500.0, 1.55), - ]; - // The sale as one more move: held at 1.55 to the close, down to 1.2 there. - let mut carried = drawn_points(&taken); - carried.extend([(9_000.0, 1.55), (9_000.0, 1.2), (9_000.0, 1.2)]); - let store = OrderLineStore::archived( - ArchivedOrdersInput { - exit: Some(exit), - ..input() - }, - &[trace(true, ArchivedLineKind::Exit, &taken)], - ); - let sell = &store.market_draw_orders("ADAUSDT", usize::MAX)[0].lines[LineKind::Sell as usize]; - assert_eq!(sell.steps, vec![(2_000.0, 1.2)]); - assert_eq!(sell.server_points, carried); - - let mut store = OrderLineStore::archived(input(), &[]); - store.append_archived( - ArchivedOrdersInput { - exit: Some(exit), - bright: true, - ..input() - }, - &[ - trace(false, ArchivedLineKind::Exit, &[(500.0, 0.9)]), - trace(true, ArchivedLineKind::Exit, &taken), - ], - ); - let mut drawn = store.market_draw_orders("ADAUSDT", usize::MAX); - drawn.sort_by_key(|o| o.uid); - assert_eq!(drawn.len(), 2); - assert_eq!( - drawn[0].lines[LineKind::Sell as usize].steps, - vec![(500.0, 0.9)], - "an inherited exit is an ancestor's, not this trade's sale" - ); - let sell = &drawn[1].lines[LineKind::Sell as usize]; - assert_eq!(sell.steps, vec![(2_000.0, 1.2)]); - assert_eq!(sell.server_points, carried); - - // A trace that ends ON the sell price is the sale itself: nothing is added. - let store = OrderLineStore::archived( - ArchivedOrdersInput { - exit: Some(ReportExit { - price: 1.55, - ..exit - }), - ..input() - }, - &[trace(true, ArchivedLineKind::Exit, &taken)], - ); - let sell = &store.market_draw_orders("ADAUSDT", usize::MAX)[0].lines[LineKind::Sell as usize]; - assert_eq!(sell.steps, vec![(2_000.0, 1.55)]); - assert_eq!(sell.server_points, drawn_points(&taken)); - - // Nor for a price a float's width off it: the report's and the wire's two pipes. - let store = OrderLineStore::archived( - ArchivedOrdersInput { - exit: Some(ReportExit { - price: 1.550_000_1, - ..exit - }), - ..input() - }, - &[trace(true, ArchivedLineKind::Exit, &taken)], - ); - let sell = &store.market_draw_orders("ADAUSDT", usize::MAX)[0].lines[LineKind::Sell as usize]; - assert_eq!(sell.server_points, drawn_points(&taken)); - - // A trace off the anchor-and-triples layout keeps its points: one appended would be read - // off its grid. The line still stands at the sale. - let odd = [(2_000.0, 1.6), (2_500.0, 1.55)]; - let store = OrderLineStore::archived( - ArchivedOrdersInput { - exit: Some(exit), - ..input() - }, - &[trace(true, ArchivedLineKind::Exit, &odd)], - ); - let sell = &store.market_draw_orders("ADAUSDT", usize::MAX)[0].lines[LineKind::Sell as usize]; - assert_eq!(sell.steps, vec![(2_000.0, 1.2)]); - assert_eq!(sell.server_points, drawn_points(&odd)); -} diff --git a/crates/moon-ui-gpui/src/analytics/mod.rs b/crates/moon-ui-gpui/src/analytics/mod.rs index 99daa4b2..35e25ba7 100644 --- a/crates/moon-ui-gpui/src/analytics/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/mod.rs @@ -624,6 +624,11 @@ pub struct AnalyticsView { /// user left inside it. Forcing either value here would destroy a choice they made on /// purpose and persisted. side_collapsed: bool, + /// The shell colour the window's clear colour was last set to. The window paints no + /// background of its own (`MoonBackgroundPolicy::NoFill`) so the chart of the tuner's trade + /// pane — drawn UNDER the GPUI scene — shows through; the clear colour stands in for the + /// root's fill and follows the palette from `render`, set only when it moved. + clear_shell: Option, /// Threshold tuner (Filters mode), with its state defined in its own module. tuner: tuner::TunerState, /// The "By coin" mode: the table's view controls, the picked coins that define @@ -1074,6 +1079,7 @@ impl AnalyticsView { kpi_collapsed: saved_kpi_collapsed, hist_collapsed: saved_hist_collapsed, side_collapsed: saved_side_collapsed, + clear_shell: None, tuner: tuner::TunerState::load( saved_tuner_iters, saved_tuner_edges, @@ -2375,7 +2381,10 @@ pub fn open( if let Ok(handle) = cx.open_window(opts, move |window, cx| { crate::window::windowing::configure_shell_clear_color(window, cx); let view = cx.new(|cx| AnalyticsView::new(b, window, cx)); - cx.new(|cx| Root::new(view, window, cx).background_policy(MoonBackgroundPolicy::Opaque)) + // NoFill: the tuner's trade pane draws a chart UNDER the GPUI scene, and an opaque root + // (or any fill above the pane) hides it whole. The clear colour is the shell's, the fill + // the root used to paint; `AnalyticsView::render` keeps it on the palette. + cx.new(|cx| Root::new(view, window, cx).background_policy(MoonBackgroundPolicy::NoFill)) }) { backend.update(cx, |bk, _| bk.analytics_window = Some(handle)); crate::window::windowing::activate_new_window(handle.into(), cx); diff --git a/crates/moon-ui-gpui/src/analytics/render.rs b/crates/moon-ui-gpui/src/analytics/render.rs index 315da02e..1f5c0ba1 100644 --- a/crates/moon-ui-gpui/src/analytics/render.rs +++ b/crates/moon-ui-gpui/src/analytics/render.rs @@ -27,6 +27,12 @@ impl Render for AnalyticsView { self.sync_period_pickers(window, cx); } let p = MoonPalette::active(cx); + // The window's background is its clear colour (see `clear_shell`): re-set only when the + // palette moved it. + if self.clear_shell != Some(p.shell) { + self.clear_shell = Some(p.shell); + crate::window::windowing::configure_shell_clear_color(window, cx); + } let (unit, split) = match self.tab { Tab::Summary => (self.data.unit(), self.data.split().cloned()), Tab::Strategies => ( @@ -61,7 +67,8 @@ impl Render for AnalyticsView { v_flex() .size_full() .relative() - .bg(moon(p.shell)) + // No fill here: the clear colour is the shell (`clear_shell`), and a fill would cover + // the trade pane's chart, which draws under the scene. .text_color(moon(p.text)) .font_family(design::mono()) .text_size(design::t_body(cx)) diff --git a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs index 1bba468f..3ca47554 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs @@ -679,8 +679,16 @@ impl AnalyticsView { // The coin table sits UNDER the list, where the histogram sits in "Filters": // both are "the detail behind the selected strategy". StratMode::Coins => left = left.children(coins_card), - // The deal table, in the same slot: the trades behind the selected strategy. - StratMode::Ticks => left = left.children(ticks_card), + // The deal table, in the same slot: the trades behind the selected strategy — and + // under it, behind a rail of its own, the pane drawing the selected deal. + StratMode::Ticks => { + left = left + .children(ticks_card) + .child(self.ticks_trade_rail(p, cx)); + if self.ticks.trade.open { + left = left.child(self.ticks_trade_pane(p, cx)); + } + } StratMode::Time => unreachable!("Time mode returns early above"), } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs index 3e90dbe6..ca2be45e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs @@ -26,12 +26,15 @@ pub(in crate::analytics::tuner) enum Align { } pub(in crate::analytics::tuner) const COL_COIN: &str = "coin"; +pub(in crate::analytics::tuner) const COL_KIND: &str = "kind"; pub(in crate::analytics::tuner) const COL_CORE: &str = "core"; /// The entry time — the table's default order (newest first), not a column: a deal is found /// by its coin and core, and the time is one double-click away in the trade window. pub(in crate::analytics::tuner) const COL_TIME: &str = "time"; pub(in crate::analytics::tuner) const COL_RESULT: &str = "result"; pub(in crate::analytics::tuner) const COL_PROFIT: &str = "profit"; +/// What В1 makes of the deal, in the sample's money — the per-deal share of the В1 column. +pub(in crate::analytics::tuner) const COL_PLAN: &str = "plan"; pub(in crate::analytics::tuner) const COL_DURATION: &str = "duration"; pub(in crate::analytics::tuner) const COL_HELD: &str = "held"; pub(in crate::analytics::tuner) const COL_REASON: &str = "reason"; @@ -48,13 +51,20 @@ const fn col(key: &'static str, label: &'static str, w: f32, min_w: f32, align: } } -/// The columns after the coin, in reading order: whose core, what came of it (per cent and -/// money), how long it was held, how much tape the terminal holds around it, why it closed, +/// The columns after the coin, in reading order: the strategy kind, whose core, what came of it +/// (per cent and money) and what В1 would make of it, how long it was held, how much tape the terminal holds around it, why it closed, /// and the two marks of this axis. The prices and the market deltas were dropped on /// 2026-09-20, the entry time on 2026-09-21: this table is the axis's SAMPLE — which trades /// have their tape and how the model does on them — and every other figure is one /// double-click away in the trade window. pub(in crate::analytics::tuner) const DEAL_COLS: &[DealCol] = &[ + col( + COL_KIND, + "analytics.ticks.col.kind", + 76.0, + 52.0, + Align::Left, + ), col(COL_CORE, "analytics.col.core", 72.0, 48.0, Align::Left), col( COL_RESULT, @@ -66,8 +76,15 @@ pub(in crate::analytics::tuner) const DEAL_COLS: &[DealCol] = &[ col( COL_PROFIT, "analytics.ticks.col.profit", - 72.0, - 56.0, + 96.0, + 60.0, + Align::Right, + ), + col( + COL_PLAN, + "analytics.ticks.col.plan", + 104.0, + 60.0, Align::Right, ), col( @@ -82,7 +99,7 @@ pub(in crate::analytics::tuner) const DEAL_COLS: &[DealCol] = &[ "analytics.ticks.col.held", 76.0, 60.0, - Align::Right, + Align::Center, ), col( COL_REASON, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index 4e3e9f8d..add14515 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -7,7 +7,9 @@ //! shown; the switch under the table narrows it to the sample the variants and the search run //! on — the rows whose tape covers the window AND which the model reproduces (`DealRow::fit`) — //! and the status line says how many that is out of the scope; the table's head opens the model's -//! settings (`cfg.rs`), which decide its ✓ column. Right: the shared KPI matrix over the fit rows +//! settings (`cfg.rs`), which decide its ✓ column. Under the table, behind a rail, the trade pane +//! (`trade_pane.rs`): the selected deal as its trade window draws it, with the variants' trades +//! beside the fact. Right: the shared KPI matrix over the fit rows //! (captioned with the ✓ shares) and the two variant columns, and the parameter grid laid out as //! the "By filter" one — the strategies' values, the variant columns, the search row with its //! settings popover. @@ -43,6 +45,7 @@ mod load; pub(in crate::analytics) mod model_cfg; pub(in crate::analytics::tuner) mod rows; pub(in crate::analytics) mod state; +mod trade_pane; mod variants; impl AnalyticsView { @@ -139,7 +142,19 @@ impl AnalyticsView { let order = view.ticks.order.as_ref()?; let row = view.ticks.data.data()?.rows.get(*order.order.get(ix)?)?; - Some(deal_row(row, weak.clone(), p, scale, row_h, app)) + let selected = view.ticks.trade.open + && view.ticks.trade.uid == Some(row.deal.report_uid); + let plan = PlanCell::of(&view.ticks, row.deal.report_uid); + Some(deal_row( + row, + selected, + plan, + weak.clone(), + p, + scale, + row_h, + app, + )) }) .unwrap_or_else(|| div().into_any_element()) }) @@ -355,17 +370,28 @@ impl AnalyticsView { /// report_uid: The row's `ReportUID` — the core's own key for the trade, not the replica's row id. /// cx: View context. fn open_deal_window(&mut self, report_uid: i64, cx: &mut Context) { - let Some(row) = self + let Some((target, axis)) = self.deal_target(report_uid) else { + return; + }; + crate::trade_window::open_record::open_trade_record(&self.backend, axis, target, cx); + } + + /// The replica row one deal of the table names, with the axis its captions render on — for + /// the trade window and for the trade pane alike. `None` when the row is not in the table or + /// has no address. + fn deal_target( + &self, + report_uid: i64, + ) -> Option<( + crate::trade_window::open_record::RecordTarget, + moon_core::db::ReportAxis, + )> { + let row = self .ticks .data .data() - .and_then(|d| d.rows.iter().find(|r| r.deal.report_uid == report_uid)) - else { - return; - }; - let Some(address) = row.address.as_ref() else { - return; - }; + .and_then(|d| d.rows.iter().find(|r| r.deal.report_uid == report_uid))?; + let address = row.address.as_ref()?; let q = self.query(); // The window's neighbours are the core's other trades of this coin over the axis's // period — the Query's bounds are true UTC and `to` is exclusive, as the filter's @@ -386,7 +412,7 @@ impl AnalyticsView { market: address.market.clone(), filter, }; - crate::trade_window::open_record::open_trade_record(&self.backend, q.axis, target, cx); + Some((target, q.axis)) } /// The table's heading row: every column sortable, the arrow on the active one. Each @@ -397,11 +423,14 @@ impl AnalyticsView { let sortable = |id: SharedString, title: String, key: &'static str, col: Option<&DealCol>| { let arrow = sort_arrow_of(&self.ticks.sort, key); + let tip = title.clone(); match col { Some(col) => deal_cell(col, scale), None => coin_cell(scale), } .id(id) + // The heading is cut to its column like a cell; the whole of it on hover. + .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(tip.clone())).into()) .cursor_pointer() .text_color(if arrow.is_empty() { moon(p.text_soft) @@ -576,15 +605,77 @@ impl AnalyticsView { } } -/// The heading of one column. The profit column names its unit — the cells are bare numbers, -/// and `Deal::profit` is USDT whatever the scope's own quote or metric (the ticker is -/// language-neutral, see locales/README.md). +/// The heading of one column. The two money columns name their unit — the cells are bare +/// numbers: `Deal::profit` is USDT whatever the scope's own quote or metric (the ticker is +/// language-neutral, see locales/README.md); the plan follows the active metric — per cent in +/// percent mode, else the sample's money under its ticker (`PlanCell::render` prints the same +/// choice). fn column_title(col: &DealCol) -> String { let title = t!(col.label).to_string(); - if col.key == COL_PROFIT { - format!("{title}, USDT") - } else { - title + match col.key { + COL_PROFIT => format!("{title}, USDT"), + COL_PLAN => match crate::analytics::pnl_unit_label() { + "" => title, + unit => format!("{title}, {unit}"), + }, + _ => title, + } +} + +/// What the plan column shows for one deal: each variant's `(money, per cent)` where the variant +/// was scored, `None` in an outer slot for a variant not scored at all. +#[derive(Clone, Copy)] +struct PlanCell([Option>; 2]); + +impl PlanCell { + /// The deal's plan under both variants, from the state the columns were scored into. + fn of(state: &state::TicksState, uid: i64) -> Self { + Self(std::array::from_fn(|i| { + state + .var_stats + .get(i) + .and_then(|s| s.as_ref()) + .map(|_| state.plan[i].get(&uid).copied()) + })) + } + + /// The cell's text, colour and tooltip: В1's result — per cent in percent mode, the + /// sample's money otherwise, as the heading says — a dash where В1 makes no trade of the + /// deal, nothing while В1 is untouched. + fn render(self, p: MoonPalette) -> (String, u32, Option) { + let pct = crate::analytics::pnl_is_pct(); + let pick = |(money, percent): (f64, f64)| if pct { percent } else { money }; + let money = |value: Option<(f64, f64)>| match value { + Some(v) => super::super::summary::fmt_signed(pick(v)), + None => "—".to_string(), + }; + let tip = || { + let parts: Vec = self + .0 + .iter() + .enumerate() + .filter_map(|(i, v)| { + v.map(|v| format!("{} {}", t!("analytics.ticks.var_n", n = i + 1), money(v))) + }) + .collect(); + (!parts.is_empty()) + .then(|| format!("{} · {}", parts.join(" · "), t!("analytics.ticks.plan_tip"))) + }; + match self.0[0] { + None => (String::new(), p.text_muted, tip()), + Some(None) => ("—".to_string(), p.text_muted, tip()), + Some(Some(v)) => ( + money(Some(v)), + if pick(v) > 0.0 { + p.green + } else if pick(v) < 0.0 { + p.red + } else { + p.text_muted + }, + tip(), + ), + } } } @@ -725,9 +816,13 @@ fn model_mark(row: &DealRow) -> (String, String) { ) } -/// One deal row. A double-click opens the trade window on it, as a Report row does. +/// One deal row. A click shows it in the trade pane, while the pane is open; a double-click +/// opens the trade window on it, as a Report row does. +#[allow(clippy::too_many_arguments)] fn deal_row( row: &DealRow, + selected: bool, + plan: PlanCell, view: WeakEntity, p: MoonPalette, scale: f32, @@ -759,12 +854,14 @@ fn deal_row( .px(design::ui_px(cx, DEAL_ROW_PAD_X)) .gap(design::ui_px(cx, DEAL_ROW_GAP)) .items_center() - .bg(moon(p.table_body)) + .bg(moon(if selected { p.panel_high } else { p.table_body })) .border_t_1() .border_color(moon_alpha(p.border, 0.5)) .child(coin_cell(scale).child(d.coin.clone())); for col in DEAL_COLS { let (value, color, tip) = match col.key { + COL_KIND => (d.kind.clone(), p.text_muted, Some(d.kind.clone())), + COL_PLAN => plan.render(p), // The core by the name the report carries; a row that carries none names it by // its uid, which is still an address. COL_CORE => ( @@ -846,11 +943,13 @@ fn deal_row( el = el .hover(move |s| s.bg(moon_alpha(p.panel_high, 0.9))) .on_click(move |ev: &ClickEvent, _window, app| { - if ev.click_count() < 2 { - return; - } // The view may already be gone; a dropped window is not an error here. - let _ = view.update(app, |this, cx| this.open_deal_window(uid, cx)); + let _ = view.update(app, |this, cx| match ev.click_count() { + // The first click of a double-click lands here too: the pane shows the deal the + // window then opens on. + 1 => this.ticks_select_deal(uid, cx), + _ => this.open_deal_window(uid, cx), + }); }); el.into_any_element() } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs index 3e58f8ca..f56a99a7 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs @@ -25,7 +25,7 @@ pub(in crate::analytics::tuner) fn order_for(state: &mut TicksState) -> &[usize] .filter(|&i| !state.only_fit || rows[i].fit()) .collect(); if let Some((key, desc)) = &state.sort { - sort_indices(rows, &mut order, key, *desc); + sort_indices(rows, &state.plan[0], &mut order, key, *desc); } state.order = Some(OrderCache { rows_rev: state.rows_rev, @@ -77,7 +77,13 @@ fn model_rank(row: &DealRow) -> u8 { } } -fn sort_indices(rows: &[DealRow], order: &mut [usize], key: &str, desc: bool) { +fn sort_indices( + rows: &[DealRow], + plan: &std::collections::HashMap, + order: &mut [usize], + key: &str, + desc: bool, +) { let by_f64 = |f: &dyn Fn(&DealRow) -> f64, order: &mut [usize]| { order.sort_by(|&a, &b| { let (x, y) = (f(&rows[a]), f(&rows[b])); @@ -90,6 +96,10 @@ fn sort_indices(rows: &[DealRow], order: &mut [usize], key: &str, desc: bool) { let c = rows[a].deal.coin.cmp(&rows[b].deal.coin); if desc { c.reverse() } else { c } }), + COL_KIND => order.sort_by(|&a, &b| { + let c = rows[a].deal.kind.cmp(&rows[b].deal.kind); + if desc { c.reverse() } else { c } + }), COL_CORE => order.sort_by(|&a, &b| { let c = rows[a].deal.core_name.cmp(&rows[b].deal.core_name); if desc { c.reverse() } else { c } @@ -97,6 +107,21 @@ fn sort_indices(rows: &[DealRow], order: &mut [usize], key: &str, desc: bool) { COL_RESULT => by_f64(&result_pct, order), // Unpriced sorts as the smallest. COL_PROFIT => by_f64(&|r| r.deal.profit.unwrap_or(f64::MIN), order), + // By the number the cell shows — per cent in percent mode, money otherwise; a deal В1 + // makes no trade of sorts as the smallest, like an unpriced one. + COL_PLAN => { + let pct = crate::analytics::pnl_is_pct(); + by_f64( + &|r| { + plan.get(&r.deal.report_uid) + .map_or( + f64::MIN, + |(money, percent)| if pct { *percent } else { *money }, + ) + }, + order, + ) + } COL_DURATION => by_f64(&|r| (r.deal.close_ms - r.deal.buy_ms) as f64, order), // By the trail — the half the exit horizon is taken from; nothing held is the shortest. COL_HELD => by_f64( diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 3bb2acf7..6ad52f55 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -346,17 +346,24 @@ pub(in crate::analytics) struct TicksState { /// Whether that task is still in its loop — it ends with the batch, and the next batch /// attaches a fresh one. pub(in crate::analytics::tuner) fetch_listening: std::sync::Arc, + /// Generation of the tape stage in flight; an older stage's answer is dropped (`load.rs`). + pub(in crate::analytics::tuner) tape_seq: u64, /// The model settings every judged row of the table was judged under, when one set is known /// — what lets a reload carry a row's verdict instead of replaying it (`load.rs`, stage B). /// `None` until a tape stage has judged the table, and from a change of the settings until /// the stage that re-judges it has folded. - /// Generation of the tape stage in flight; an older stage's answer is dropped (`load.rs`). - pub(in crate::analytics::tuner) tape_seq: u64, pub(in crate::analytics::tuner) judged_under: Option, /// Whether the tape stage of a load is still reading the rows' tape off the worker: until /// it folds, every addressed row reads "missing" without meaning it. pub(in crate::analytics::tuner) tape_reading: bool, + /// The trade pane under the table (`trade_pane.rs`). + pub(in crate::analytics::tuner) trade: super::trade_pane::TradePane, + /// Each variant's result per deal, by `ReportUID`, as `(money in the sample's unit, per cent)` + /// — what the column counted for that deal (`variant_tally_by_deal`). A deal absent is one the variant makes + /// no trade of, or one outside the replayed sample. Scored with the columns, cleared with + /// them. + pub(in crate::analytics::tuner) plan: [HashMap; N_VAR], } impl Default for TicksState { @@ -396,6 +403,8 @@ impl Default for TicksState { tape_reading: false, judged_under: None, tape_seq: 0, + trade: Default::default(), + plan: Default::default(), } } } @@ -424,6 +433,7 @@ impl TicksState { self.passes = saved.passes.map(|n| n.to_string()).unwrap_or_default(); self.gate_pct = saved.gate_pct.map(|n| n.to_string()).unwrap_or_default(); self.locked = saved.locked.iter().cloned().collect(); + self.trade.open = saved.trade_open; } /// The axis' settings as the layout persists them, the model's from their process-wide @@ -440,6 +450,7 @@ impl TicksState { gate_pct: number(&self.gate_pct), locked, model: super::model_cfg::current(), + trade_open: self.trade.open, } } @@ -460,6 +471,7 @@ impl TicksState { self.var_seq = self.var_seq.wrapping_add(1); self.var_task = None; self.var_stats = Default::default(); + self.plan = Default::default(); self.stop_search(); // A note about the previous scope's search says nothing about this one. self.sugg_note = None; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs new file mode 100644 index 00000000..84775ca6 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs @@ -0,0 +1,304 @@ +//! The trade pane under the deal table: the selected deal drawn as its trade window draws it — +//! the same view (`trade_window::Host::Embedded`), not a second chart — with the trades the +//! variant columns would have made beside the fact, В1 dashed and В2 dotted: the path each +//! variant's entry order walked, its fill and exit, and — under the window's MoonShot zone switch +//! — the corridor the model held around that order, placement by placement. +//! +//! Folded by default, behind a rail like the one between the halves of the tab; folded, a click +//! on a row selects nothing and nothing is built. Open, a click on a row shows that deal; a +//! double-click still opens its window. +//! +//! The modelled trades are one replay per touched variant on the selected deal's tape — the same +//! parameters and the same tape cut the columns are scored on (`search::variant_picture`) — run +//! whenever the pane shows a new deal and whenever the columns are rescored, for the deals of +//! the sample only (`DealRow::fit`): a trade the model does not reproduce has no variant to +//! draw, as it has no plan in the table. + +use gpui::prelude::FluentBuilder; +use gpui::*; +use moon_chart::frozen_overlay::{OverlayBand, OverlayTrade}; +use moon_chart::layers::{SEG_PATTERN_DASH, SEG_PATTERN_DOT}; +use moon_core::db::tuner::ticks::ExitKind; +use moon_core::db::tuner::ticks::search::{PreparedDeal, clip_to_horizon, variant_picture}; +use moon_ui::{MoonPalette, h_flex, v_flex}; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +use super::super::shared::{N_VAR, collapse_caret}; +use crate::design; +use crate::design::moon; +use crate::trade_window::TradeWindowView; + +/// The pane's state, in `TicksState`. +#[derive(Default)] +pub(in crate::analytics::tuner) struct TradePane { + /// Whether the pane is open; the layout remembers it (`TicksAxisLayout::trade_open`). + pub(in crate::analytics::tuner) open: bool, + /// The deal the pane shows, by `ReportUID` — kept while the pane is folded, so opening it + /// again brings the same deal back. + pub(in crate::analytics::tuner) uid: Option, + /// The view drawing it: `None` while the deal resolves, and while the pane is folded — a + /// folded pane holds no chart. + view: Option>, + /// The replica could not resolve the deal. + missing: bool, + /// Generation of the resolve in flight; an older answer is dropped. + seq: u64, + /// Generation of the modelled-trades replay in flight; an older answer is dropped. + model_seq: u64, +} + +/// The pen of a variant's modelled trade: В1 dashed, В2 dotted — the fact keeps its solid lines. +fn variant_pattern(index: usize) -> f32 { + match index { + 0 => SEG_PATTERN_DASH, + _ => SEG_PATTERN_DOT, + } +} + +impl AnalyticsView { + /// Fold or open the pane, and remember it. Folding drops the chart; opening shows the deal + /// the pane showed last, if any. + pub(in crate::analytics::tuner) fn ticks_toggle_trade_pane(&mut self, cx: &mut Context) { + let pane = &mut self.ticks.trade; + pane.open = !pane.open; + pane.seq = pane.seq.wrapping_add(1); + pane.model_seq = pane.model_seq.wrapping_add(1); + pane.view = None; + pane.missing = false; + if let Some(uid) = pane.uid.filter(|_| pane.open) { + self.ticks_show_deal(uid, cx); + } + self.persist_ticks_settings(cx); + cx.notify(); + } + + /// A click on a row: show that deal in the pane, while it is open. A folded pane ignores it. + pub(in crate::analytics::tuner) fn ticks_select_deal( + &mut self, + uid: i64, + cx: &mut Context, + ) { + let pane = &self.ticks.trade; + if !pane.open || (pane.uid == Some(uid) && (pane.view.is_some() || !pane.missing)) { + return; + } + self.ticks.trade.uid = Some(uid); + self.ticks_show_deal(uid, cx); + cx.notify(); + } + + /// Resolve one deal off the replica and build the pane's view on it. + fn ticks_show_deal(&mut self, uid: i64, cx: &mut Context) { + let pane = &mut self.ticks.trade; + pane.seq = pane.seq.wrapping_add(1); + pane.model_seq = pane.model_seq.wrapping_add(1); + pane.view = None; + pane.missing = false; + let seq = pane.seq; + let Some((target, axis)) = self.deal_target(uid) else { + self.ticks.trade.missing = true; + return; + }; + let weak = cx.entity().downgrade(); + crate::trade_window::open_record::resolve_trade_record( + axis, + target, + cx, + move |seed, app| { + let _ = weak.update(app, |this, cx| { + let pane = &mut this.ticks.trade; + if pane.seq != seq || !pane.open { + return; + } + match seed { + Some(seed) => { + let backend = this.backend.clone(); + this.ticks.trade.view = + Some(crate::trade_window::embedded_trade_view(&backend, seed, cx)); + this.ticks_refresh_model_trades(cx); + } + None => pane.missing = true, + } + cx.notify(); + }); + }, + ); + } + + /// Replay every touched variant on the pane's deal and hand the trades to its view. Nothing + /// to replay — no view, no tape in memory for the deal, no variant touched — hands it none. + pub(in crate::analytics::tuner) fn ticks_refresh_model_trades( + &mut self, + cx: &mut Context, + ) { + let Some(view) = self.ticks.trade.view.clone() else { + return; + }; + self.ticks.trade.model_seq = self.ticks.trade.model_seq.wrapping_add(1); + let seq = self.ticks.trade.model_seq; + let changes: Vec> = + (0..N_VAR).map(|i| self.ticks.variant_changes(i)).collect(); + let job = self.ticks.data.data().and_then(|data| { + let uid = self.ticks.trade.uid?; + let row = data + .rows + .iter() + .find(|r| r.deal.report_uid == uid) + .filter(|r| r.fit())?; + if changes.iter().all(Vec::is_empty) { + return None; + } + let mut deal = PreparedDeal { + deal: row.deal.clone(), + ticks: row.ticks.clone()?, + entry_line: row.entry_line.clone(), + trail_ms: row.held.map(|(_, trail)| trail).unwrap_or(0), + }; + // The columns' own cut, so the picture shows what the column counted. + if let Some(horizon_ms) = data.exit_horizon_ms() { + clip_to_horizon(std::slice::from_mut(&mut deal), horizon_ms); + } + Some(( + deal, + super::variants::base_of(&data.now), + data.single_kind().unwrap_or_default().to_string(), + )) + }); + let Some((deal, base, kind)) = job else { + view.update(cx, |view, cx| { + view.set_model_trades(Vec::new(), Vec::new(), cx) + }); + return; + }; + let defaults = self.filter_defaults(cx); + let model = super::model_cfg::current(); + let is_short = deal.deal.is_short; + cx.spawn(async move |this, cx| { + let executor = cx.update(|cx| cx.background_executor().clone()); + let pictures = executor + .spawn(async move { + changes + .iter() + .map(|values| { + (!values.is_empty()).then(|| { + variant_picture(&deal, &base, &defaults, &kind, values, model) + }) + }) + .collect::>() + }) + .await; + let mut corridor: Vec = Vec::new(); + let trades: Vec = pictures + .into_iter() + .enumerate() + .filter_map(|(index, picture)| { + let picture = picture?; + let outcome = picture.outcome; + let fill = outcome.fill?; + // Each placement's corridor until the next placement, the last one until the + // fill. + let steps = &picture.corridor; + for (i, step) in steps.iter().enumerate() { + let to_ms = steps.get(i + 1).map_or(fill.t_ms, |next| next.t_ms); + if step.t_ms < fill.t_ms { + corridor.push(OverlayBand { + from_ms: step.t_ms as f64, + to_ms: to_ms.min(fill.t_ms) as f64, + prices: (step.band.0 as f32, step.band.1 as f32), + }); + } + } + Some(OverlayTrade { + path: steps + .iter() + .map(|step| (step.t_ms as f64, step.level as f32)) + .collect(), + fill_ms: fill.t_ms as f64, + fill_price: fill.price as f32, + exit: outcome + .exit + .filter(|exit| exit.kind != ExitKind::OpenAtWindowEnd) + .map(|exit| (exit.t_ms as f64, exit.price as f32)), + is_short, + pattern: variant_pattern(index), + }) + }) + .collect(); + let _ = cx.update(|cx| { + let _ = this.update(cx, |this, cx| { + if this.ticks.trade.model_seq != seq { + return; + } + view.update(cx, |view, cx| view.set_model_trades(trades, corridor, cx)); + }); + }); + }) + .detach(); + } + + /// The rail that folds the pane: the same caret the rail between the halves carries, laid + /// across the column, with what the pane draws in words. + pub(in crate::analytics::tuner) fn ticks_trade_rail( + &self, + p: MoonPalette, + cx: &Context, + ) -> AnyElement { + let open = self.ticks.trade.open; + h_flex() + .w_full() + .flex_none() + .h(design::ui_px(cx, 16.0)) + .items_center() + .justify_center() + .gap(design::ui_px(cx, 6.0)) + .child(collapse_caret( + "an-ticks-trade-collapse", + !open, + t!("analytics.ticks.trade_collapse").to_string(), + t!("analytics.ticks.trade_expand").to_string(), + p, + cx.listener(|this, _, _, cx| this.ticks_toggle_trade_pane(cx)), + )) + .when(open, |el| { + el.child( + div() + .font_family(design::ui_font()) + .text_size(design::t_caption(cx)) + .text_color(moon(p.text_muted)) + .child(t!("analytics.ticks.trade_legend").to_string()), + ) + }) + .into_any_element() + } + + /// The open pane: the deal's view, or what stands in for it. + pub(in crate::analytics::tuner) fn ticks_trade_pane( + &self, + p: MoonPalette, + cx: &Context, + ) -> AnyElement { + let pane = &self.ticks.trade; + let body = match (&pane.view, pane.uid) { + (Some(view), _) => div().size_full().child(view.clone()).into_any_element(), + (None, uid) => { + let key = match uid { + None => "analytics.ticks.trade_pick", + Some(_) if pane.missing => "analytics.ticks.trade_missing", + Some(_) => "analytics.ticks.trade_loading", + }; + crate::load_state::muted(t!(key).to_string(), 10.0, p, cx) + } + }; + v_flex() + .w_full() + .flex_1() + .min_h_0() + .rounded(design::ui_px(cx, 8.0)) + .border_1() + .border_color(moon(p.border)) + .overflow_hidden() + .child(body) + .into_any_element() + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 8ba82a09..d2ecc4cb 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -25,7 +25,7 @@ use moon_core::db::tuner::ticks::TICK_PARAMS; use moon_core::db::tuner::ticks::params::ParamGroup; use moon_core::db::tuner::ticks::search::{ DEFAULT_MAX_PASSES, PreparedDeal, SearchParams, clip_to_horizon, common_horizon_ms, suggest, - variant_tally, + variant_tally_by_deal, }; use moon_core::db::tuner::ticks::stats_of; @@ -54,7 +54,7 @@ pub(super) fn passes_of(text: &str) -> usize { } /// The base every variant is laid over: the fields the selected strategies agree on. -fn base_of(now: &HashMap) -> HashMap { +pub(super) fn base_of(now: &HashMap) -> HashMap { now.iter() .filter_map(|(key, value)| match value { NowValue::Same(v) if !v.is_empty() => Some((key.clone(), v.clone())), @@ -100,6 +100,9 @@ impl AnalyticsView { // replay — a fetch over hundreds of rows re-arms this once per row. if (0..N_VAR).all(|i| self.ticks.variant_changes(i).is_empty()) { self.ticks.var_stats = Default::default(); + self.set_ticks_plan(Default::default()); + // The trade pane's modelled trades go with the columns. + self.ticks_refresh_model_trades(cx); return; } let req = self.ticks.var_seq; @@ -140,9 +143,13 @@ impl AnalyticsView { if values.is_empty() || deals.is_empty() { return None; } - let (tally, spent) = - variant_tally(&deals, &base, &defaults, &kind, values, model); - Some(stats_of(tally, spent)) + let (tally, spent, money) = + variant_tally_by_deal(&deals, &base, &defaults, &kind, values, model); + let plan: HashMap = money + .into_iter() + .filter_map(|(uid, value)| Some((uid, value?))) + .collect(); + Some((stats_of(tally, spent), plan)) }) .collect::>() }, @@ -150,15 +157,41 @@ impl AnalyticsView { if this.ticks.var_seq != req { return; } - for (slot, value) in this.ticks.var_stats.iter_mut().zip(stats) { - *slot = value; + let mut plan: [HashMap; N_VAR] = Default::default(); + for ((slot, value), plan) in this + .ticks + .var_stats + .iter_mut() + .zip(stats) + .zip(plan.iter_mut()) + { + *slot = value.map(|(stats, deals)| { + *plan = deals; + stats + }); } + this.set_ticks_plan(plan); this.ticks.var_n = n; + // The trade pane draws what the columns now count. + this.ticks_refresh_model_trades(cx); cx.notify(); }, ); } + /// Take the variants' per-deal results; a table sorted by the plan column is re-sorted. + fn set_ticks_plan(&mut self, plan: [HashMap; N_VAR]) { + self.ticks.plan = plan; + if self + .ticks + .sort + .as_ref() + .is_some_and(|(key, _)| key == super::columns::COL_PLAN) + { + self.ticks.order = None; + } + } + /// One cell of a variant changed: store it and rescore. pub(in crate::analytics::tuner) fn set_ticks_variant( &mut self, @@ -193,6 +226,7 @@ impl AnalyticsView { ) { self.ticks.variants[index].clear(); self.ticks.var_stats[index] = None; + self.ticks.plan[index].clear(); self.ticks_reset_inputs_of(index); self.arm_ticks_variants(cx); cx.notify(); diff --git a/crates/moon-ui-gpui/src/trade_window/mod.rs b/crates/moon-ui-gpui/src/trade_window/mod.rs index a27c2fd0..b1ad9704 100644 --- a/crates/moon-ui-gpui/src/trade_window/mod.rs +++ b/crates/moon-ui-gpui/src/trade_window/mod.rs @@ -69,7 +69,7 @@ use moon_core::market::trade_replay::{ use moon_core::session::CoreId; use moon_core::venue::Brand; -pub(crate) use window::{TradeSeed, open_trade_window}; +pub(crate) use window::{TradeSeed, embedded_trade_view, open_trade_window}; /// Where a trade view lives. pub(crate) enum Host { @@ -84,10 +84,6 @@ pub(crate) enum Host { cascade_px: f32, }, /// A pane inside another view — the tuner's deal table. See the module doc. - #[expect( - dead_code, - reason = "the tuner's Entry/Exit axis, its consumer, lands separately" - )] Embedded, } @@ -606,10 +602,6 @@ impl TradeWindowView { /// trades: The modelled trades, in Unix UTC ms. /// corridor: Their corridors, shaded while the MoonShot zone switch is on. /// cx: View context. - #[expect( - dead_code, - reason = "the tuner's Entry/Exit axis, its consumer, lands separately" - )] pub(crate) fn set_model_trades( &mut self, trades: Vec, diff --git a/crates/moon-ui-gpui/src/trade_window/open_record.rs b/crates/moon-ui-gpui/src/trade_window/open_record.rs index 911f6370..b4550f7f 100644 --- a/crates/moon-ui-gpui/src/trade_window/open_record.rs +++ b/crates/moon-ui-gpui/src/trade_window/open_record.rs @@ -113,6 +113,36 @@ pub(crate) fn open_trade_record( cx: &mut App, ) { let backend = backend.clone(); + resolve_trade_record(axis, target, cx, move |seed, cx| { + let Some(seed) = seed else { + return; + }; + let super::TradeSeed { + record, + meta, + history, + market, + stamps, + } = seed; + super::open_trade_window(&backend, record, meta, history, market, stamps, cx); + }); +} + +/// Read a target's trade off the replica, off the UI thread, and hand it to `done` on it — the +/// one read both the window and the tuner's trade pane are built from. +/// +/// Args: +/// axis: Time axis the captions render on; see [`open_trade_record`]. +/// target: The already-resolved row. +/// cx: Application context. +/// done: Called once on the UI thread with the resolved trade, or `None` when the replica +/// could not resolve it. +pub(crate) fn resolve_trade_record( + axis: db::ReportAxis, + target: RecordTarget, + cx: &mut App, + done: impl FnOnce(Option, &mut App) + 'static, +) { let RecordTarget { core, coin, @@ -128,14 +158,20 @@ pub(crate) fn open_trade_record( .spawn(async move { load_trade(core, coin, record, filter) }) .await; cx.update(|cx| { - let Some((record, meta, history)) = found else { - return; - }; - let stamps = ( - stamp(&axis, core, record.buy_stamp()), - stamp(&axis, core, record.close_stamp()), - ); - super::open_trade_window(&backend, record, meta, history, market, stamps, cx); + let seed = found.map(|(record, meta, history)| { + let stamps = ( + stamp(&axis, core, record.buy_stamp()), + stamp(&axis, core, record.close_stamp()), + ); + super::TradeSeed { + record, + meta, + history, + market, + stamps, + } + }); + done(seed, cx); }); }) .detach(); diff --git a/crates/moon-ui-gpui/src/trade_window/window.rs b/crates/moon-ui-gpui/src/trade_window/window.rs index fe589ba6..a3c9d8ca 100644 --- a/crates/moon-ui-gpui/src/trade_window/window.rs +++ b/crates/moon-ui-gpui/src/trade_window/window.rs @@ -186,10 +186,6 @@ pub(crate) struct TradeSeed { /// /// Returns: /// The view, for the host to render and to hand modelled trades to. -#[expect( - dead_code, - reason = "the tuner's Entry/Exit axis, its consumer, lands separately" -)] pub(crate) fn embedded_trade_view( backend: &Entity, seed: TradeSeed, diff --git a/crates/moon-ui-gpui/tests/theme_contract/windowing.rs b/crates/moon-ui-gpui/tests/theme_contract/windowing.rs index b65203d5..19e2b9a3 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/windowing.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/windowing.rs @@ -879,8 +879,8 @@ fn historical_trade_windows_leave_no_live_order_or_market_action_route() { "the trade window must never route a trading or figure action through `{forbidden}`" ); } - // Every host builds the view through the one constructor, so the pin is checked there, and - // the window opener must go through it. + // Both hosts — a window of its own and the tuner's pane — build the view through the one + // constructor, so the pin is checked there, and the window opener must go through it. assert!( code_only(braced_body( &trade_window, diff --git a/locales/analytics.yml b/locales/analytics.yml index 81a89248..68192fd2 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1752,6 +1752,30 @@ analytics.ticks.deltas_tip_market: ru: "снимок отчёта — средняя по всем рынкам биржи" en: "the report's snapshot — an average over every market of the exchange" es: "la instantánea del informe — un promedio de todos los mercados del exchange" +analytics.ticks.trade_expand: + ru: "Показать окно сделки — клик по строке покажет её здесь" + en: "Show the trade pane — a click on a row shows it here" + es: "Mostrar el panel de la operación — un clic en una fila la muestra aquí" +analytics.ticks.trade_collapse: + ru: "Свернуть окно сделки" + en: "Collapse the trade pane" + es: "Contraer el panel de la operación" +analytics.ticks.trade_legend: + ru: "факт — сплошные, В1 — штрих, В2 — пунктир" + en: "fact solid, V1 dashed, V2 dotted" + es: "real continuo, V1 a trazos, V2 punteado" +analytics.ticks.trade_pick: + ru: "Выберите сделку в таблице" + en: "Pick a trade in the table" + es: "Elija una operación en la tabla" +analytics.ticks.trade_loading: + ru: "Загружаю сделку…" + en: "Loading the trade…" + es: "Cargando la operación…" +analytics.ticks.trade_missing: + ru: "Сделка не найдена в отчёте" + en: "The trade is not in the report" + es: "La operación no está en el informe" analytics.ticks.only_fit: ru: "только годные" en: "reproduced only" @@ -1788,6 +1812,18 @@ analytics.ticks.fetch_waiting: ru: "трейды: %{done}/%{total} · ждём биржу" en: "trades: %{done}/%{total} · waiting for the venue" es: "trades: %{done}/%{total} · esperando al exchange" +analytics.ticks.col.kind: + ru: "тип" + en: "kind" + es: "tipo" +analytics.ticks.col.plan: + ru: "план В1" + en: "plan V1" + es: "plan V1" +analytics.ticks.plan_tip: + ru: "что вариант даёт этой сделке — как её считает колонка варианта, без комиссий; прочерк — вариант её не торгует или она вне выборки" + en: "what the variant makes of this trade — as the variant's column counts it, before fees; a dash — the variant makes no trade of it, or it is outside the sample" + es: "lo que la variante obtiene de esta operación — como la cuenta la columna de la variante, sin comisiones; un guion — la variante no la opera o queda fuera de la muestra" analytics.ticks.col.result: ru: "%" en: "%" From 0cc43bfb497bf71a24e5795974f88a722b841158 Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 23:35:39 +0200 Subject: [PATCH 30/51] fix(chart): end a closed trade's archived exit line at the report's sale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/session/order_lines/archived/tests.rs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/crates/moon-core/src/session/order_lines/archived/tests.rs b/crates/moon-core/src/session/order_lines/archived/tests.rs index 0b67cf05..17232762 100644 --- a/crates/moon-core/src/session/order_lines/archived/tests.rs +++ b/crates/moon-core/src/session/order_lines/archived/tests.rs @@ -380,3 +380,107 @@ fn an_archived_own_entry_line_replaces_the_reports_and_neighbours_fall_back() { vec![(700.0, 1.0)] ); } + +/// Archived points as the store draws them: the price narrowed to the chart's `f32`. +fn drawn_points(points: &[(f64, f64)]) -> Vec<(f64, f32)> { + points.iter().map(|&(t, p)| (t, p as f32)).collect() +} + +/// A stop sold by market leaves no price on the sell order: the core's trace stops at the take +/// it last stood at, and the report alone says where the position closed. The exit line stands at +/// the report's sell price, and the trace is carried to it at the close — the trade window and a +/// "Moonbot lines" chart alike. +#[test] +fn an_exit_trace_that_ends_off_the_sell_price_is_carried_to_it_at_the_close() { + let exit = ReportExit { + price: 1.2, + set_ms: 2_000.0, + }; + // An anchor and one move, as the core writes them: held at 1.6 to 2_500, down to 1.55. + let taken = [ + (2_000.0, 1.6), + (2_500.0, 1.6), + (2_450.0, 1.55), + (2_500.0, 1.55), + ]; + // The sale as one more move: held at 1.55 to the close, down to 1.2 there. + let mut carried = drawn_points(&taken); + carried.extend([(9_000.0, 1.55), (9_000.0, 1.2), (9_000.0, 1.2)]); + let store = OrderLineStore::archived( + ArchivedOrdersInput { + exit: Some(exit), + ..input() + }, + &[trace(true, ArchivedLineKind::Exit, &taken)], + ); + let sell = &store.market_draw_orders("ADAUSDT", usize::MAX)[0].lines[LineKind::Sell as usize]; + assert_eq!(sell.steps, vec![(2_000.0, 1.2)]); + assert_eq!(sell.server_points, carried); + + let mut store = OrderLineStore::archived(input(), &[]); + store.append_archived( + ArchivedOrdersInput { + exit: Some(exit), + bright: true, + ..input() + }, + &[ + trace(false, ArchivedLineKind::Exit, &[(500.0, 0.9)]), + trace(true, ArchivedLineKind::Exit, &taken), + ], + ); + let mut drawn = store.market_draw_orders("ADAUSDT", usize::MAX); + drawn.sort_by_key(|o| o.uid); + assert_eq!(drawn.len(), 2); + assert_eq!( + drawn[0].lines[LineKind::Sell as usize].steps, + vec![(500.0, 0.9)], + "an inherited exit is an ancestor's, not this trade's sale" + ); + let sell = &drawn[1].lines[LineKind::Sell as usize]; + assert_eq!(sell.steps, vec![(2_000.0, 1.2)]); + assert_eq!(sell.server_points, carried); + + // A trace that ends ON the sell price is the sale itself: nothing is added. + let store = OrderLineStore::archived( + ArchivedOrdersInput { + exit: Some(ReportExit { + price: 1.55, + ..exit + }), + ..input() + }, + &[trace(true, ArchivedLineKind::Exit, &taken)], + ); + let sell = &store.market_draw_orders("ADAUSDT", usize::MAX)[0].lines[LineKind::Sell as usize]; + assert_eq!(sell.steps, vec![(2_000.0, 1.55)]); + assert_eq!(sell.server_points, drawn_points(&taken)); + + // Nor for a price a float's width off it: the report's and the wire's two pipes. + let store = OrderLineStore::archived( + ArchivedOrdersInput { + exit: Some(ReportExit { + price: 1.550_000_1, + ..exit + }), + ..input() + }, + &[trace(true, ArchivedLineKind::Exit, &taken)], + ); + let sell = &store.market_draw_orders("ADAUSDT", usize::MAX)[0].lines[LineKind::Sell as usize]; + assert_eq!(sell.server_points, drawn_points(&taken)); + + // A trace off the anchor-and-triples layout keeps its points: one appended would be read + // off its grid. The line still stands at the sale. + let odd = [(2_000.0, 1.6), (2_500.0, 1.55)]; + let store = OrderLineStore::archived( + ArchivedOrdersInput { + exit: Some(exit), + ..input() + }, + &[trace(true, ArchivedLineKind::Exit, &odd)], + ); + let sell = &store.market_draw_orders("ADAUSDT", usize::MAX)[0].lines[LineKind::Sell as usize]; + assert_eq!(sell.steps, vec![(2_000.0, 1.2)]); + assert_eq!(sell.server_points, drawn_points(&odd)); +} From c307ed5527dc89931101536fba6f4a8332caede5 Mon Sep 17 00:00:00 2001 From: guyverino Date: Wed, 23 Sep 2026 23:35:39 +0200 Subject: [PATCH 31/51] fix(tuner): replay each deal under its own strategy, keep the search and the tapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/moon-core/src/db/tuner/ticks/search.rs | 226 ++++++++++++++---- .../src/db/tuner/ticks/search/tests.rs | 149 +++++++++++- .../src/analytics/tuner/ticks/load.rs | 107 +++++++-- .../src/analytics/tuner/ticks/mod.rs | 1 + .../src/analytics/tuner/ticks/rows/tests.rs | 86 +++++++ .../src/analytics/tuner/ticks/state.rs | 103 ++++++-- .../src/analytics/tuner/ticks/tape.rs | 149 ++++++++++++ .../src/analytics/tuner/ticks/tape/tests.rs | 78 ++++++ .../src/analytics/tuner/ticks/trade_pane.rs | 31 +-- .../src/analytics/tuner/ticks/variants.rs | 130 +++++----- locales/analytics.yml | 8 + 11 files changed, 891 insertions(+), 177 deletions(-) create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/tape.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/tape/tests.rs diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index 25004346..d4df2935 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -3,7 +3,9 @@ //! it — the same shape as `threshold_search`, with the SQL mask replaced by [`simulate`]. //! //! A point is a set of strategy values in the strategy's own spelling, laid over the values -//! the strategies hold now; the model's parameter structs are built from that overlay through +//! each deal's OWN strategy holds now ([`PreparedDeal::own`]) — a field the point leaves alone +//! runs every deal at its strategy's value, never at a default because the selected strategies +//! disagree on it; the model's parameter structs are built from that overlay through //! the same builders the grid's "now" column uses, so what the search varies is exactly what //! Save writes. Only the fields of the groups the caller switched on are searched, minus the //! ones it locked. @@ -59,6 +61,11 @@ pub struct PreparedDeal { /// sample's horizon from; a covered row holds at least the model's tail /// (`required_spans`), so it is never under `TAIL_MS` there. pub trail_ms: i64, + /// The values the deal's own strategy holds now, in strategy spelling — the base every + /// variant and every point of a search is laid over on THIS deal. Shared by the deals of + /// one strategy; empty when the strategy could not be read, and then every field reads as + /// default. + pub own: Arc>, } /// Cut every deal's tape at the same distance past its close — the exit horizon the whole @@ -95,10 +102,11 @@ pub fn common_horizon_ms(deals: &[PreparedDeal]) -> Option { /// What one search varies and how. pub struct SearchParams<'a> { - /// The values every selected strategy holds now, in strategy spelling — the base every - /// point is laid over. Fields the strategies disagree on are absent and read as default. - pub base: &'a HashMap, - /// Schema defaults for the keys the base leaves out. + /// Values held over every deal's own base ([`PreparedDeal::own`]) before the point is laid + /// on — the variant's edits when one field is searched in it (the searched field among + /// them, which the point then overrides); empty otherwise. + pub held: &'a HashMap, + /// Schema defaults for the keys a deal's base leaves out. pub defaults: &'a HashMap, /// The strategy kind of the sample, for the fields the grid offers. pub kind: &'a str, @@ -106,7 +114,7 @@ pub struct SearchParams<'a> { pub vary_entry: bool, /// Whether the Exit group is searched. pub vary_exit: bool, - /// Field keys held at the base value. + /// Field keys held at each deal's base value. pub locked: &'a HashSet, /// Restart count, at least 1. pub restarts: usize, @@ -125,7 +133,8 @@ pub struct SearchParams<'a> { /// What the search found. #[derive(Clone, Debug)] pub struct SearchResult { - /// The winning values, in strategy spelling — only the fields that moved off the base. + /// The winning values, in strategy spelling — only the fields that moved off the base of + /// at least one deal. pub values: Vec<(String, String)>, /// What they achieve on the deals they were fitted on. pub train: Tally, @@ -138,15 +147,20 @@ pub struct SearchResult { /// One point of the grid: the varied fields' values, in strategy spelling. type Point = HashMap<&'static str, String>; -/// The model parameters a point comes to. +/// The model parameters a point comes to on a deal whose strategy holds `own`, with `held` laid +/// over it first. fn params_of( - base: &HashMap, + own: &HashMap, + held: &HashMap, defaults: &HashMap, point: &Point, kind: &str, model: ModelSettings, ) -> (EntryParams, ExitParams) { - let mut values = base.clone(); + let mut values = own.clone(); + for (key, value) in held { + values.insert(key.clone(), value.clone()); + } for (key, value) in point { values.insert((*key).to_string(), value.clone()); } @@ -206,17 +220,89 @@ fn varied<'a>(p: &SearchParams<'a>) -> Vec<&'static super::params::TickParam> { .collect() } -/// The tally of a point over `deals`, in order, and the spend of the deals it traded. -fn tally_and_spent(deals: &[PreparedDeal], entry: &EntryParams, exit: &ExitParams) -> (Tally, f64) { - // The replay of every deal is independent; the tally is folded in order afterwards. - let results: Vec> = deals +/// The distinct strategy bases of a sample ([`PreparedDeal::own`]) and which one each deal runs +/// over: a point's parameters are built once per strategy, not once per deal. +struct Bases<'a> { + owns: Vec<&'a HashMap>, + /// Each deal's index into `owns`, in the deals' order. + of_deal: Vec, +} + +impl<'a> Bases<'a> { + fn of(deals: &'a [PreparedDeal]) -> Self { + let mut owns: Vec<&'a HashMap> = Vec::new(); + let of_deal = deals + .iter() + .map(|d| { + let own = d.own.as_ref(); + match owns.iter().position(|o| std::ptr::eq(*o, own) || *o == own) { + Some(index) => index, + None => { + owns.push(own); + owns.len() - 1 + } + } + }) + .collect(); + Self { owns, of_deal } + } + + /// A point's parameters on every base, in the bases' order. + fn params( + &self, + held: &HashMap, + defaults: &HashMap, + point: &Point, + kind: &str, + model: ModelSettings, + ) -> Vec<(EntryParams, ExitParams)> { + self.owns + .iter() + .map(|own| params_of(own, held, defaults, point, kind, model)) + .collect() + } + + /// Whether `value` of `key` is something at least one base, under `held`, does not hold. + fn moves(&self, held: &HashMap, key: &str, value: &str) -> bool { + self.owns + .iter() + .any(|own| held.get(key).or_else(|| own.get(key)).map(String::as_str) != Some(value)) + } +} + +/// Every deal's result under one point, in order — `(money, spent)`, `None` where the point +/// makes no trade of the deal. +/// +/// Args: +/// deals: The deals. +/// of_deal: Each deal's index into `params` ([`Bases::of_deal`]), as long as `deals`. +/// params: The point's parameters per base ([`Bases::params`]). +fn results( + deals: &[PreparedDeal], + of_deal: &[usize], + params: &[(EntryParams, ExitParams)], +) -> Vec> { + deals .par_iter() - .map(|d| { + .zip(of_deal.par_iter()) + .map(|(d, &base)| { + let (entry, exit) = ¶ms[base]; simulate(&d.deal, &d.ticks, entry, exit, d.entry_line.as_deref()) .profit_money(&d.deal) .map(|money| (money, d.deal.spent)) }) - .collect(); + .collect() +} + +/// The tally of a point over `deals`, in order, and the spend of the deals it traded; arguments +/// as for [`results`]. +fn tally_and_spent( + deals: &[PreparedDeal], + of_deal: &[usize], + params: &[(EntryParams, ExitParams)], +) -> (Tally, f64) { + // The replay of every deal is independent; the tally is folded in order afterwards. + let results = results(deals, of_deal, params); let mut tally = Tally::default(); let mut spent = 0.0; for (money, size) in results.into_iter().flatten() { @@ -226,9 +312,9 @@ fn tally_and_spent(deals: &[PreparedDeal], entry: &EntryParams, exit: &ExitParam (tally, spent) } -/// The tally of a point over `deals`, in order. -fn tally(deals: &[PreparedDeal], entry: &EntryParams, exit: &ExitParams) -> Tally { - tally_and_spent(deals, entry, exit).0 +/// The tally of a point over `deals`, in order; arguments as for [`results`]. +fn tally(deals: &[PreparedDeal], of_deal: &[usize], params: &[(EntryParams, ExitParams)]) -> Tally { + tally_and_spent(deals, of_deal, params).0 } /// Whether `a` beats `b` under the objective, with the sample floor. @@ -254,6 +340,17 @@ fn next_random(state: &mut u64) -> u64 { x.wrapping_mul(0x2545_F491_4F6C_DD1D) } +/// How many deals of a sample, oldest first, the search fits on under `train_frac` — the slice +/// its `min_n` floor is held on; the rest is the holdout. Taken on the close stamps alone, so a +/// caller can ask before it has the tapes at hand. +/// +/// Args: +/// closes: The sample's close stamps, chronological. +/// train_frac: [`SearchParams::train_frac`]. +pub fn train_len(closes: &[i64], train_frac: f64) -> usize { + train_split(closes, train_frac) +} + /// Run the search. /// /// Args: @@ -262,8 +359,9 @@ fn next_random(state: &mut u64) -> u64 { /// handle: Stop and progress; a fresh one per run. /// /// Returns: -/// The best point found, or `None` when the sample is empty, nothing is varied, or the -/// run was stopped before its first restart finished. +/// The best point found, or `None` when the sample is empty, nothing is varied, the run +/// was stopped before its first restart finished, or no point it visited keeps `min_n` +/// trades — the richest point under the floor is not what the caller asked for. pub fn suggest( deals: &[PreparedDeal], params: &SearchParams<'_>, @@ -274,7 +372,7 @@ pub fn suggest( return None; } let closes: Vec = deals.iter().map(|d| d.deal.close_ms).collect(); - let train_n = train_split(&closes, params.train_frac); + let train_n = train_len(&closes, params.train_frac); let train = &deals[..train_n]; let min_n = params .min_n @@ -290,9 +388,11 @@ pub fn suggest( let restarts = params.restarts.max(1); let max_passes = params.max_passes.max(1); let model = params.model.sanitized(); + let bases = Bases::of(deals); + let train_of = &bases.of_deal[..train_n]; let evaluate = |point: &Point| -> Tally { - let (entry, exit) = params_of(params.base, params.defaults, point, params.kind, model); - tally(train, &entry, &exit) + let per_base = bases.params(params.held, params.defaults, point, params.kind, model); + tally(train, train_of, &per_base) }; let best = install(|| { (0..restarts) @@ -367,16 +467,22 @@ pub fn suggest( }) })?; let (_, point, train_tally) = best; - // The base's own spelling of a field is not a change; only what moved is reported. + // `better` ranks a point under the floor below any above it, so a best under it means no + // point held the floor at all. + if train_tally.n < min_n { + return None; + } + // A field every deal's base already spells so is not a change; only what moved is + // reported — a value one strategy holds and another does not is a change for the other. let mut values: Vec<(String, String)> = point .iter() - .filter(|(key, value)| params.base.get(**key) != Some(*value)) + .filter(|(key, value)| bases.moves(params.held, key, value)) .map(|(key, value)| ((*key).to_string(), value.clone())) .collect(); values.sort(); let holdout = (train_n < deals.len()).then(|| { - let (entry, exit) = params_of(params.base, params.defaults, &point, params.kind, model); - tally(&deals[train_n..], &entry, &exit) + let per_base = bases.params(params.held, params.defaults, &point, params.kind, model); + tally(&deals[train_n..], &bases.of_deal[train_n..], &per_base) }); Some(SearchResult { values, @@ -390,22 +496,28 @@ pub fn suggest( /// the deals it traded. /// /// Args: -/// deals: The covered deals, chronological. -/// base: The strategies' current values. +/// deals: The covered deals, chronological; each is run over its own strategy's values +/// ([`PreparedDeal::own`]). /// defaults: Schema defaults. /// kind: The strategy kind. -/// values: The variant's changes over the base, in strategy spelling. +/// values: The variant's changes over each deal's base, in strategy spelling. /// model: The model's own settings, the entry method among them. pub fn variant_tally( deals: &[PreparedDeal], - base: &HashMap, defaults: &HashMap, kind: &str, values: &[(String, String)], model: ModelSettings, ) -> (Tally, f64) { - let (entry, exit) = variant_params(base, defaults, kind, values, model); - install(|| tally_and_spent(deals, &entry, &exit)) + let bases = Bases::of(deals); + let per_base = bases.params( + &HashMap::new(), + defaults, + &point_of(values), + kind, + model.sanitized(), + ); + install(|| tally_and_spent(deals, &bases.of_deal, &per_base)) } /// One variant on one deal, as the tuner's trade pane draws it. @@ -425,19 +537,25 @@ pub struct VariantPicture { /// /// Args: /// deal: The deal with its tape, cut at the sample's horizon as the column's are. -/// base, defaults, kind, values, model: As for [`variant_tally`]. +/// defaults, kind, values, model: As for [`variant_tally`]. /// /// Returns: /// The modelled outcome and corridor. pub fn variant_picture( deal: &PreparedDeal, - base: &HashMap, defaults: &HashMap, kind: &str, values: &[(String, String)], model: ModelSettings, ) -> VariantPicture { - let (entry, exit) = variant_params(base, defaults, kind, values, model); + let (entry, exit) = params_of( + &deal.own, + &HashMap::new(), + defaults, + &point_of(values), + kind, + model.sanitized(), + ); let line = deal.entry_line.as_deref(); let outcome = simulate(&deal.deal, &deal.ticks, &entry, &exit, line); // A variant that keeps the trade's own entry fills where the report says (`simulate`), and @@ -472,24 +590,32 @@ pub type DealResults = Vec<(i64, Option<(f64, f64)>)>; /// [`variant_tally`]. /// /// Args: -/// deals, base, defaults, kind, values, model: As for [`variant_tally`]. +/// deals, defaults, kind, values, model: As for [`variant_tally`]. /// /// Returns: /// The tally, the spent sum, and each deal's result ([`DealResults`]). pub fn variant_tally_by_deal( deals: &[PreparedDeal], - base: &HashMap, defaults: &HashMap, kind: &str, values: &[(String, String)], model: ModelSettings, ) -> (Tally, f64, DealResults) { - let (entry, exit) = variant_params(base, defaults, kind, values, model); + let bases = Bases::of(deals); + let per_base = bases.params( + &HashMap::new(), + defaults, + &point_of(values), + kind, + model.sanitized(), + ); install(|| { let money: DealResults = deals .par_iter() - .map(|d| { - let outcome = simulate(&d.deal, &d.ticks, &entry, &exit, d.entry_line.as_deref()); + .zip(bases.of_deal.par_iter()) + .map(|(d, &base)| { + let (entry, exit) = &per_base[base]; + let outcome = simulate(&d.deal, &d.ticks, entry, exit, d.entry_line.as_deref()); let result = outcome.profit_money(&d.deal).zip(outcome.profit_pct); (d.deal.report_uid, result) }) @@ -506,23 +632,17 @@ pub fn variant_tally_by_deal( }) } -/// The entry and exit parameters of a variant's changes laid over the base — the one reading -/// [`variant_tally`], [`variant_tally_by_deal`] and [`variant_picture`] take, so a column, its -/// per-deal share and a picture cannot differ. -fn variant_params( - base: &HashMap, - defaults: &HashMap, - kind: &str, - values: &[(String, String)], - model: ModelSettings, -) -> (EntryParams, ExitParams) { +/// A variant's changes as a point — the one reading [`variant_tally`], +/// [`variant_tally_by_deal`] and [`variant_picture`] take, so a column, its per-deal share and a +/// picture cannot differ. A key the grid does not know is dropped. +fn point_of(values: &[(String, String)]) -> Point { let mut point = Point::new(); for (key, value) in values { if let Some(field) = TICK_PARAMS.iter().find(|f| f.key == key) { point.insert(field.key, value.clone()); } } - params_of(base, defaults, &point, kind, model.sanitized()) + point } #[cfg(test)] diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index a3e5421b..378d46cc 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -62,9 +62,22 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { ticks: Arc::from(ticks), entry_line: None, trail_ms: 0, + own: Arc::new(base()), } } +/// A deal of `prepared` whose own strategy takes at `take` per cent. +fn with_take(uid: i64, take: &str) -> PreparedDeal { + let mut deal = prepared(uid, 101.0); + deal.own = Arc::new( + [("SellPrice", take), ("StopLoss", "0")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ); + deal +} + /// The stamp of a tape's last print past the deal's close. fn tape_end_ms(d: &PreparedDeal) -> i64 { d.ticks @@ -84,7 +97,7 @@ fn base() -> HashMap { fn the_search_raises_the_take_to_what_every_tape_reaches() { // Every deal peaks at 101.0: a take of 1 % fills on all of them; 1.2 % on none. let deals: Vec = (1..=8).map(|uid| prepared(uid, 101.0)).collect(); - let base = base(); + let held = HashMap::new(); let defaults = HashMap::new(); let mut locked: HashSet = TICK_PARAMS .iter() @@ -93,7 +106,7 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { .collect(); locked.remove("SellPrice"); let params = SearchParams { - base: &base, + held: &held, defaults: &defaults, kind: "PumpsDetection", vary_entry: false, @@ -127,7 +140,6 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { // The same values through the variant column. let (tally, spent) = variant_tally( &deals, - &base, &defaults, "PumpsDetection", &result.values, @@ -142,7 +154,6 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { // entry at the fact, the take at 1 % on the peak. let picture = variant_picture( &deals[0], - &base, &defaults, "PumpsDetection", &result.values, @@ -161,7 +172,6 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { // And the deal table's plan column: every deal's money, summing to the column's tally. let (by_tally, by_spent, money) = variant_tally_by_deal( &deals, - &base, &defaults, "PumpsDetection", &result.values, @@ -187,12 +197,12 @@ fn the_holdout_is_scored_but_never_fitted_on() { .map(|uid| prepared(uid, 101.0)) .chain((7..=8).map(|uid| prepared(uid, 100.5))) .collect(); - let base = base(); + let held = HashMap::new(); let defaults = HashMap::new(); let mut locked: HashSet = TICK_PARAMS.iter().map(|f| f.key.to_string()).collect(); locked.remove("SellPrice"); let params = SearchParams { - base: &base, + held: &held, defaults: &defaults, kind: "PumpsDetection", vary_entry: false, @@ -219,11 +229,11 @@ fn the_holdout_is_scored_but_never_fitted_on() { #[test] fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { let deals: Vec = (1..=3).map(|uid| prepared(uid, 101.0)).collect(); - let base = base(); + let held = HashMap::new(); let defaults = HashMap::new(); let all: HashSet = TICK_PARAMS.iter().map(|f| f.key.to_string()).collect(); let params = SearchParams { - base: &base, + held: &held, defaults: &defaults, kind: "PumpsDetection", vary_entry: true, @@ -318,12 +328,12 @@ fn the_common_horizon_is_the_shortest_held_trail_and_clips_only_the_longer_tapes /// the corridor model searches them all. #[test] fn a_shift_does_not_search_the_path_only_fields() { - let base = base(); + let held = HashMap::new(); let defaults = HashMap::new(); let locked = HashSet::new(); let keys = |method| { let params = SearchParams { - base: &base, + held: &held, defaults: &defaults, kind: "MoonShot", vary_entry: true, @@ -354,3 +364,120 @@ fn a_shift_does_not_search_the_path_only_fields() { } assert!(shift.contains(&"MShotPrice") && shift.contains(&"MShotAddDistance")); } + +#[test] +fn a_field_the_strategies_disagree_on_runs_at_each_deals_own_value() { + // Two strategies, one take each: 1 % and 0.2 %. A variant that leaves the take alone must + // run every deal at its own strategy's take — 10 on the first deal, 2 on the second — and + // not at a default for a field no one strategy holds for all. + let deals = vec![with_take(1, "1"), with_take(2, "0.2")]; + let (tally, _, money) = variant_tally_by_deal( + &deals, + &HashMap::new(), + "PumpsDetection", + &[("StopLoss".to_string(), "0".to_string())], + ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + ); + let money: Vec> = money.iter().map(|(_, m)| m.map(|(v, _)| v)).collect(); + assert_eq!(tally.n, 2, "{money:?}"); + assert!( + (money[0].unwrap_or(f64::NAN) - 10.0).abs() < 1e-6, + "{money:?}" + ); + assert!( + (money[1].unwrap_or(f64::NAN) - 2.0).abs() < 1e-6, + "{money:?}" + ); +} + +#[test] +fn a_search_holds_each_deals_own_value_and_reports_a_value_one_strategy_lacks() { + // Two strategies, takes of 1 % and 0.2 %, every tape peaking at 101. Left alone, each deal + // keeps its own take: 10 and 2. Searched: 1 % wins on both, and it is a change — the second + // strategy does not hold it — even though the first already does. + let deals: Vec = (1..=4) + .map(|uid| with_take(uid, if uid % 2 == 1 { "1" } else { "0.2" })) + .collect(); + let held = HashMap::new(); + let defaults = HashMap::new(); + let mut locked: HashSet = TICK_PARAMS.iter().map(|f| f.key.to_string()).collect(); + let model = ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }; + let (tally, _) = variant_tally(&deals, &defaults, "PumpsDetection", &[], model); + assert!((tally.profit - 24.0).abs() < 1e-6, "{}", tally.profit); + locked.remove("SellPrice"); + let params = SearchParams { + held: &held, + defaults: &defaults, + kind: "PumpsDetection", + vary_entry: false, + vary_exit: true, + locked: &locked, + restarts: 3, + min_n: Some(2), + seed: Some(7), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + model, + }; + let result = suggest(&deals, ¶ms, &SearchHandle::new()).expect("a result"); + assert_eq!( + result.values, + vec![("SellPrice".to_string(), "1".to_string())], + "{result:?}" + ); + assert!( + (result.train.profit - 40.0).abs() < 1e-6, + "{}", + result.train.profit + ); + // Held over every base, the same take is no change at all. + let held: HashMap = [("SellPrice".to_string(), "1".to_string())].into(); + let params = SearchParams { + held: &held, + ..params + }; + let result = suggest(&deals, ¶ms, &SearchHandle::new()).expect("a result"); + assert!(result.values.is_empty(), "{result:?}"); +} + +/// A floor no point can hold is not an answer: the search says it found nothing rather than +/// hand back the richest point that trades fewer deals than asked. +#[test] +fn a_trade_floor_no_point_keeps_finds_nothing() { + let deals: Vec = (1..=8).map(|uid| prepared(uid, 101.0)).collect(); + let held = HashMap::new(); + let defaults = HashMap::new(); + let mut locked: HashSet = TICK_PARAMS.iter().map(|f| f.key.to_string()).collect(); + locked.remove("SellPrice"); + let params = SearchParams { + held: &held, + defaults: &defaults, + kind: "PumpsDetection", + vary_entry: false, + vary_exit: true, + locked: &locked, + restarts: 3, + min_n: Some(9), + seed: Some(7), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + }; + let result = suggest(&deals, ¶ms, &SearchHandle::new()); + assert!(result.is_none(), "{result:?}"); + // Held by every point, the same search answers. + let params = SearchParams { + min_n: Some(8), + ..params + }; + assert!(suggest(&deals, ¶ms, &SearchHandle::new()).is_some()); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 25b110f8..205e84d3 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -20,7 +20,8 @@ use std::time::Duration; use gpui::*; use super::super::super::AnalyticsView; -use super::state::{DealRow, NowValue, RowAddress, TapeStatus, TicksData}; +use super::state::{DealRow, NowValue, OwnValues, RowAddress, TapeStatus, TicksData}; +use super::tape; use crate::analytics::bg::ReadLane; use crate::analytics::refresh::{CatchUpOutcome, report_result_is_stale}; use moon_core::db::ReadFail; @@ -45,7 +46,11 @@ use moon_core::market::trade_replay::{ const HELD_ANSWER_WAIT: Duration = Duration::from_secs(240); /// What stage A brings back: the deals and the grid's "now" values. -type StageA = (Result, HashMap); +type StageA = ( + Result, + HashMap, + OwnValues, +); impl AnalyticsView { /// Recompute the axis for the current scope. @@ -75,15 +80,21 @@ impl AnalyticsView { ReadLane::Ticks, ReadLane::TicksReplay, ReadLane::TicksVariants, - ReadLane::TicksSearch, ]); self.ticks.seq = self.ticks.seq.wrapping_add(1); // The tape stage the cancel above dropped is not reading any more; the new load's own // stage C raises the flag again when it starts. self.ticks.tape_reading = false; - // A search over the previous deal set answers nothing about the new one; the lane - // cancel above does not reach its handle, only this does. - self.ticks.stop_search(); + // A search over a scope the user left answers nothing about the new one; the lane + // cancel does not reach its handle, only this does. A report that moved is the SAME + // scope with a trade more — a trade closing on any core, every few seconds on a busy + // fleet — and the search runs on a copy of the deals: it finishes into В1 and the + // columns are rescored over the reloaded rows. Stopped there, a run of a minute never + // finished at all, and said nothing. + if !after_report { + self.latest_reads.cancel(&[ReadLane::TicksSearch]); + self.ticks.stop_search(); + } let req = self.ticks.seq; let report_req = self.current_report_generation(); let q = self.tuner_query(); @@ -120,10 +131,14 @@ impl AnalyticsView { q.strategies.len() ), } - let now = now_values(&targets, &keys); - (deals, now) + let own = deals + .as_ref() + .map(|read| own_values(&read.deals, &keys)) + .unwrap_or_default(); + let now = now_values(&targets, &keys, &own); + (deals, now, own) }, - move |this, (deals, now): StageA, cx| { + move |this, (deals, now, own): StageA, cx| { if this.ticks.seq != req { return; } @@ -146,7 +161,15 @@ impl AnalyticsView { } }; let addresses = this.resolve_addresses(&read.deals, cx); - this.start_replay_stage(req, report_req, after_report, read, now, addresses, cx); + this.start_replay_stage( + req, + report_req, + after_report, + read, + (now, own), + addresses, + cx, + ); }, ); } @@ -181,7 +204,7 @@ impl AnalyticsView { report_req: u64, after_report: bool, read: DealsRead, - now: HashMap, + (now, own): (HashMap, OwnValues), addresses: HashMap<(u64, String), Option>>, cx: &mut Context, ) { @@ -217,6 +240,7 @@ impl AnalyticsView { let before = judged .get(&deal.report_uid) .filter(|before| address.is_some() && carryable(&before.deal, &deal)) + .filter(|before| after_report || !before.lost_tape()) .cloned(); let mut row = DealRow { deal, @@ -253,6 +277,7 @@ impl AnalyticsView { exit_share: (0, 0), kinds, now, + own, }; data.retain_within_cap(); data.refresh_summary(); @@ -337,8 +362,14 @@ impl AnalyticsView { .filter_map(|r| Some((r.deal.clone(), r.address.clone()?))) .collect(); if targets.is_empty() { + log_tape_budget(data); self.ticks.tape_reading = false; self.ticks.judged_under = Some(model); + // Every row was carried: no fold follows to rescore the variant columns, and the + // load's `invalidate` has already dropped their scores and the plan column — a + // narrower selection over deals already judged would keep the edits and show + // nothing for them. + self.arm_ticks_variants(cx); return; } let defaults = self.filter_defaults(cx); @@ -428,6 +459,9 @@ impl AnalyticsView { this.ticks.tape_reading = false; this.ticks.judged_under = Some(model); this.ticks.update_rows(rows); + if let Some(data) = this.ticks.data.data() { + log_tape_budget(data); + } // The row the fetch job is out for says so again after the fold. this.mark_fetch_in_flight(); // The rows still missing are what the user is looking at: a running batch @@ -514,11 +548,19 @@ fn ask_held( } /// The grid's "now" column: every selected strategy's current value per field, folded to -/// one value or "varies". -fn now_values(targets: &[(i64, Option)], keys: &[String]) -> HashMap { +/// one value or "varies". A target on a known core that the deals' strategies already read +/// (`own`, from [`own_values`]) is not read again. +fn now_values( + targets: &[(i64, Option)], + keys: &[String], + own: &OwnValues, +) -> HashMap { let mut seen: HashMap>> = HashMap::new(); for &(sid, core) in targets { - let values = strategy_current_values(sid, core, keys); + let values = match core.and_then(|core| own.get(&(sid, core))) { + Some(values) => Arc::clone(values), + None => Arc::new(strategy_current_values(sid, core, keys)), + }; for key in keys { seen.entry(key.clone()) .or_default() @@ -539,6 +581,24 @@ fn now_values(targets: &[(i64, Option)], keys: &[String]) -> HashMap OwnValues { + let mut out = OwnValues::new(); + for deal in deals { + out.entry((deal.strategy_id, deal.core_uid)) + .or_insert_with(|| { + Arc::new(strategy_current_values( + deal.strategy_id, + Some(deal.core_uid), + keys, + )) + }); + } + out +} + /// What the order archive holds of one deal's own lines: the entry line's points, the exit /// line's points, and whether the core answered for the deal with lines at all — without them /// a missing entry line proves nothing (`record::entry_placement`). @@ -738,7 +798,24 @@ pub(super) fn replay_row_with( lines.entry_points.as_deref(), lines.exit_points.as_deref(), )); - row.ticks = Some(Arc::from(ticks)); + row.ticks = Some(tape::PackedTape::pack(ticks)); +} + +/// One line per load on what the table holds of its tape — the numbers the memory budget +/// is judged by, read from the log instead of guessed. +fn log_tape_budget(data: &TicksData) { + let b = data.tape_budget(); + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks tape: {} row(s), {} fit, {} with tape in memory, {} let go by the cap · {} print(s), {:.1} MiB of {:.0} MiB", + b.rows, + b.fit, + b.replayable, + b.dropped, + b.prints, + b.bytes as f64 / 1_048_576.0, + super::state::MAX_RETAINED_BYTES as f64 / 1_048_576.0 + ); } /// Whether what the last load judged of a trade still describes the row a reload read for it: diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index add14515..f747760e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -45,6 +45,7 @@ mod load; pub(in crate::analytics) mod model_cfg; pub(in crate::analytics::tuner) mod rows; pub(in crate::analytics) mod state; +mod tape; mod trade_pane; mod variants; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 525f64c8..147a8fc5 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -258,10 +258,20 @@ fn invalidate_stops_the_search_and_drops_the_variant_scores_but_keeps_the_edits( handle: handle.clone(), total: 3, }; + state.last_result = Some(moon_core::db::tuner::ticks::SearchResult { + values: Vec::new(), + train: Default::default(), + holdout: Some(Default::default()), + seed: 1, + }); state.invalidate(); assert!(handle.is_cancelled()); assert!(matches!(state.sugg, super::super::state::SuggState::Idle)); assert!(state.var_stats[0].is_none()); + assert!( + state.last_result.is_none(), + "the last search's holdout is of the previous scope's deals" + ); assert!( state.has_changes(), "the user's edits survive a scope change" @@ -361,3 +371,79 @@ fn the_fetch_edits_land_in_order_in_one_pass() { // The answer was counted: both judged rows are fit now. assert_eq!(data.fit(), 2); } + +/// A packed tape of `n` prints, one a millisecond. +fn tape_of(n: usize) -> super::super::tape::PackedTape { + super::super::tape::PackedTape::pack( + (0..n) + .map(|i| moon_core::feed::types::Tick { + time_ms: 1_000.0 + i as f64, + price: 1.0, + qty: 1.0, + side: moon_core::feed::types::Side::Buy, + }) + .collect(), + ) +} + +/// Only a fit row is ever replayed: a tape on any other row is let go, and what stays is +/// counted in bytes, packed. +#[test] +fn the_cap_keeps_only_the_fit_rows_tapes_and_counts_them_packed() { + let mut data = TicksData { + rows: vec![ + DealRow { + deal: deal(1, 1_000, 100.0, 101.0, false), + tape: TapeStatus::Covered, + verdict: Some(verdict(Some(true), Some(true))), + address: None, + ticks: Some(tape_of(10)), + entry_line: None, + held: None, + }, + DealRow { + deal: deal(2, 2_000, 100.0, 99.0, false), + tape: TapeStatus::Covered, + verdict: Some(verdict(Some(false), Some(false))), + address: None, + ticks: Some(tape_of(10)), + entry_line: None, + held: None, + }, + ], + ..TicksData::default() + }; + assert!(data.rows[0].fit() && !data.rows[1].fit()); + data.retain_within_cap(); + assert!(data.rows[0].ticks.is_some()); + assert!( + data.rows[1].ticks.is_none(), + "an unfit row's tape is never replayed" + ); + let budget = data.tape_budget(); + assert_eq!( + (budget.rows, budget.fit, budget.replayable, budget.dropped), + (2, 1, 1, 0) + ); + assert_eq!((budget.prints, budget.bytes), (10, 80)); +} + +/// A fit row the cap left tapeless is what a scope reload must read again; a row that holds +/// its tape, or one that is not fit, is carried as it is. +#[test] +fn a_fit_row_without_its_tape_is_the_one_that_lost_it() { + let mut row = DealRow { + deal: deal(1, 1_000, 100.0, 101.0, false), + tape: TapeStatus::Covered, + verdict: Some(verdict(Some(true), Some(true))), + address: None, + ticks: Some(tape_of(3)), + entry_line: None, + held: None, + }; + assert!(!row.lost_tape()); + row.ticks = None; + assert!(row.lost_tape()); + row.verdict = Some(verdict(Some(false), Some(true))); + assert!(!row.lost_tape(), "an unfit row keeps no tape by design"); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 6ad52f55..80a1d1a2 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -13,13 +13,13 @@ use gpui::Entity; use moon_ui::MoonInputState; use super::super::shared::N_VAR; +use super::tape::{PackedTape, PendingDeal}; use crate::load_state::LoadState; use moon_core::db::tuner::VarStats; use moon_core::db::tuner::threshold_search::SearchHandle; use moon_core::db::tuner::ticks::params::ParamGroup; use moon_core::db::tuner::ticks::search::SearchResult; use moon_core::db::tuner::ticks::{Deal, Verdict, fit_for_search}; -use moon_core::feed::types::Tick; use moon_core::market::trade_replay::TickStatus; /// Share of hits a group needs before it may be searched, per cent, when the search settings do @@ -27,10 +27,11 @@ use moon_core::market::trade_replay::TickStatus; /// better. The spec's proposal, to be tuned by practice (`TicksState::gate_pct`). pub(in crate::analytics::tuner) const DEFAULT_GATE_PCT: u32 = 80; -/// Ticks kept in memory across every covered row, for the variants and the search. Past it a -/// row is still "covered" — the model ran on it — but its tape is let go and the row sits out -/// of the variant columns; the caption says how many. -pub(in crate::analytics::tuner) const MAX_RETAINED_TICKS: usize = 4_000_000; +/// Bytes of tape kept in memory across every fit row, for the variants and the search — what +/// four million prints took before they were packed ([`PackedTape`]), which now holds three times +/// as many. Past it a row is still "covered" — the model ran on it — but its tape is let go and +/// the row sits out of the variant columns; the caption says how many. +pub(in crate::analytics::tuner) const MAX_RETAINED_BYTES: usize = 4_000_000 * 24; /// What the terminal holds for one deal's window. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -58,9 +59,9 @@ pub(in crate::analytics::tuner) struct DealRow { /// `(exchange_key, market)` of the deal's core, resolved at load time; `None` is /// [`TapeStatus::NoAddress`]. pub(in crate::analytics::tuner) address: Option>, - /// The window's prints, kept for the variants and the search while the row is covered and - /// the memory cap allows; `None` otherwise. - pub(in crate::analytics::tuner) ticks: Option>, + /// The window's prints, packed, kept for the variants and the search while the row is fit + /// and the memory cap allows; `None` otherwise. + pub(in crate::analytics::tuner) ticks: Option, /// The archived points of the trade's own entry line, when the archive holds it — /// where the order stood before the tape begins, and the core's first moves. pub(in crate::analytics::tuner) entry_line: Option>, @@ -79,6 +80,14 @@ impl DealRow { self.tape == TapeStatus::Covered && self.verdict.as_ref().is_some_and(fit_for_search) } + /// Whether the row is fit but holds no tape — one the memory cap let go under a wider scope. + /// Carried into a new scope it would stay tapeless: stage C reads only the rows it was not + /// handed, so the narrower selection it now fits in would never get it back. A report reload + /// is the same scope, where the cap would only drop it again; a scope reload re-reads it. + pub(in crate::analytics::tuner) fn lost_tape(&self) -> bool { + self.fit() && self.ticks.is_none() + } + /// Take everything a replay learned about this row from its answer: the tape's word, the /// verdict, the model inputs derived for the deal (the price step, the archived pre-spike /// ask and take, where the entry order was placed, the core's step lag, what the fact proves @@ -156,8 +165,29 @@ pub(in crate::analytics::tuner) struct TicksData { pub(in crate::analytics::tuner) kinds: Vec, /// The parameter grid's "now" column, by field key. pub(in crate::analytics::tuner) now: HashMap, + /// Each strategy of the rows as it stands now, by `(strategy_id, core_uid)` — the base the + /// variants and the search lay their values over on that strategy's deals + /// ([`PreparedDeal::own`]). The grid folds these into one "now" cell; a replay must not, + /// or a field the strategies disagree on runs every deal at its default. + pub(in crate::analytics::tuner) own: OwnValues, +} + +/// What [`TicksData::tape_budget`] counts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(in crate::analytics::tuner) struct TapeBudget { + pub(in crate::analytics::tuner) rows: usize, + pub(in crate::analytics::tuner) fit: usize, + /// Fit rows with their tape in memory — the sample. + pub(in crate::analytics::tuner) replayable: usize, + /// Fit rows whose tape the cap let go. + pub(in crate::analytics::tuner) dropped: usize, + pub(in crate::analytics::tuner) prints: usize, + pub(in crate::analytics::tuner) bytes: usize, } +/// Strategies' current values by `(strategy_id, core_uid)`, one shared map per strategy. +pub(in crate::analytics::tuner) type OwnValues = HashMap<(i64, u64), Arc>>; + impl TicksData { /// Rows whose window the tape covers. pub(in crate::analytics::tuner) fn covered(&self) -> usize { @@ -473,6 +503,9 @@ impl TicksState { self.var_stats = Default::default(); self.plan = Default::default(); self.stop_search(); + // Nor does the last search's holdout: В1's caption would print it beside a column + // rescored over deals it never saw. + self.last_result = None; // A note about the previous scope's search says nothing about this one. self.sugg_note = None; if let Some(data) = self.data.data_mut() { @@ -653,23 +686,63 @@ impl TicksState { } impl TicksData { - /// Let go of the tapes past the memory cap, oldest rows first — the newest deals are the - /// ones a variant is most likely to be judged on. A row whose tape is dropped stays - /// covered; it simply sits out of the variant columns. Run after every change of the - /// retained set: the bulk load and each fetched row. + /// A row as the variants and the search replay it — its tape, its archived entry line, its + /// held trail, its strategy's current values; `None` without a tape in memory. The tape + /// stays packed and uncut: the caller unpacks off the UI thread and cuts the sample at its + /// one horizon ([`prepare_sample`](super::tape::prepare_sample)). + pub(in crate::analytics::tuner) fn prepared(&self, row: &DealRow) -> Option { + Some(PendingDeal { + deal: row.deal.clone(), + tape: row.ticks.clone()?, + entry_line: row.entry_line.clone(), + trail_ms: row.held.map(|(_, trail)| trail).unwrap_or(0), + own: self + .own + .get(&(row.deal.strategy_id, row.deal.core_uid)) + .cloned() + .unwrap_or_default(), + }) + } + + /// Let go of the tapes nothing replays — a row that is not fit is never in the sample — and + /// of those past the memory cap, oldest rows first: the newest deals are the ones a variant + /// is most likely to be judged on. A row whose tape is dropped stays covered; it simply sits + /// out of the variant columns. Run after every change of the retained set: the bulk load + /// and each fetched row. pub(in crate::analytics::tuner) fn retain_within_cap(&mut self) { let mut held = 0usize; // Newest first: the rows are chronological, so walk them backwards. for row in self.rows.iter_mut().rev() { - let Some(ticks) = row.ticks.as_ref() else { + let Some(bytes) = row.ticks.as_ref().map(PackedTape::bytes) else { continue; }; - if held + ticks.len() > MAX_RETAINED_TICKS { + if !row.fit() || held + bytes > MAX_RETAINED_BYTES { row.ticks = None; } else { - held += ticks.len(); + held += bytes; + } + } + } + + /// What the table holds of its tape, for the load's log line: the rows, the fit ones, the + /// fit ones with their tape in memory, those the cap let go, and the prints and bytes held. + pub(in crate::analytics::tuner) fn tape_budget(&self) -> TapeBudget { + let mut budget = TapeBudget { + rows: self.rows.len(), + ..TapeBudget::default() + }; + for row in self.rows.iter().filter(|r| r.fit()) { + budget.fit += 1; + match &row.ticks { + Some(tape) => { + budget.replayable += 1; + budget.prints += tape.len(); + budget.bytes += tape.bytes(); + } + None => budget.dropped += 1, } } + budget } /// Recompute the fit-subset KPI and the ✓ shares from the rows — after a load or a fetch diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/tape.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tape.rs new file mode 100644 index 00000000..4c76f7b9 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tape.rs @@ -0,0 +1,149 @@ +//! A deal's prints as the table keeps them between replays. +//! +//! The table holds every fit row's tape so the variant columns and the search can replay it on +//! demand, and a [`Tick`] is 24 bytes: an `f64` time, the price, the quantity and the side. The +//! model reads the time, the price and the side, never the quantity — so between replays a +//! print is kept as 8: its offset from the tape's first print in milliseconds and its price, +//! the side in the price's sign bit. Measured on this machine's reports (2026-09-23, 265 +//! MoonShot deals): 48.9 MiB of held prints as `Tick`s, 16.3 MiB packed. +//! +//! A tape that cannot be packed without loss — a fractional millisecond, a span past what a +//! `u32` of milliseconds reaches (49 days), a price the sign bit cannot carry — is kept as it +//! came. Unpacking is done off the UI thread, right before a replay, and lives only as long as +//! that replay. + +use std::collections::HashMap; +use std::sync::Arc; + +use moon_core::db::tuner::ticks::Deal; +use moon_core::db::tuner::ticks::search::{PreparedDeal, clip_to_horizon, common_horizon_ms}; +use moon_core::feed::types::{Side, Tick}; + +/// One deal's prints, packed where that loses nothing. +#[derive(Clone, Debug)] +pub(in crate::analytics::tuner) enum PackedTape { + /// Offsets in ms from `base_ms` and prices, a sell stored as the negated price. + Packed { + base_ms: i64, + prints: Arc<[(u32, f32)]>, + }, + /// The prints as they came, for a tape [`PackedTape::pack`] could not take. + Plain(Arc<[Tick]>), +} + +impl PackedTape { + /// Keep `ticks`, packed when every print survives it unchanged. + pub(in crate::analytics::tuner) fn pack(ticks: Vec) -> Self { + match packed(&ticks) { + Some((base_ms, prints)) => Self::Packed { + base_ms, + prints: prints.into(), + }, + None => Self::Plain(ticks.into()), + } + } + + /// How many prints the tape holds. + pub(in crate::analytics::tuner) fn len(&self) -> usize { + match self { + Self::Packed { prints, .. } => prints.len(), + Self::Plain(ticks) => ticks.len(), + } + } + + /// What the tape takes in memory, in bytes — what the table's budget is counted in. + pub(in crate::analytics::tuner) fn bytes(&self) -> usize { + match self { + Self::Packed { prints, .. } => std::mem::size_of_val(&prints[..]), + Self::Plain(ticks) => std::mem::size_of_val(&ticks[..]), + } + } + + /// The prints as the model replays them. A packed tape's quantities come back as zero: the + /// model never reads one, and a caller that ever needs them must hold the tape [`Plain`]. + /// + /// [`Plain`]: PackedTape::Plain + pub(in crate::analytics::tuner) fn unpack(&self) -> Arc<[Tick]> { + match self { + Self::Packed { base_ms, prints } => prints + .iter() + .map(|&(offset, signed)| Tick { + time_ms: (base_ms + i64::from(offset)) as f64, + price: signed.abs(), + qty: 0.0, + side: if signed.is_sign_negative() { + Side::Sell + } else { + Side::Buy + }, + }) + .collect(), + Self::Plain(ticks) => Arc::clone(ticks), + } + } +} + +/// A replayable row taken off the table with its tape still packed — cheap to take on the UI +/// thread; [`PendingDeal::prepare`] unpacks it off it. +#[derive(Clone, Debug)] +pub(in crate::analytics::tuner) struct PendingDeal { + pub(in crate::analytics::tuner) deal: Deal, + pub(in crate::analytics::tuner) tape: PackedTape, + pub(in crate::analytics::tuner) entry_line: Option>, + pub(in crate::analytics::tuner) trail_ms: i64, + pub(in crate::analytics::tuner) own: Arc>, +} + +impl PendingDeal { + /// The deal as the model replays it, its tape unpacked. + pub(in crate::analytics::tuner) fn prepare(self) -> PreparedDeal { + PreparedDeal { + ticks: self.tape.unpack(), + deal: self.deal, + entry_line: self.entry_line, + trail_ms: self.trail_ms, + own: self.own, + } + } +} + +/// A sample as the columns and the search replay it: every tape unpacked and cut at the +/// sample's one exit horizon (`clip_to_horizon`), so no variant is judged on more tape than +/// another. Off the UI thread: it is the whole sample's prints at full width. +pub(in crate::analytics::tuner) fn prepare_sample(pending: Vec) -> Vec { + let mut deals: Vec = pending.into_iter().map(PendingDeal::prepare).collect(); + if let Some(horizon_ms) = common_horizon_ms(&deals) { + clip_to_horizon(&mut deals, horizon_ms); + } + deals +} + +/// `ticks` packed, or `None` when a print would not come back as it went in. +fn packed(ticks: &[Tick]) -> Option<(i64, Vec<(u32, f32)>)> { + let base = ticks.first()?.time_ms; + if base.fract() != 0.0 || !base.is_finite() { + return None; + } + let base_ms = base as i64; + ticks + .iter() + .map(|t| { + // Whole milliseconds only: the tile store keeps integers, and anything else would be + // rounded away here. + let offset = t.time_ms - base; + let price_ok = t.price.is_finite() && t.price > 0.0; + (offset.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&offset) && price_ok) + .then(|| { + let signed = match t.side { + Side::Buy => t.price, + Side::Sell => -t.price, + }; + (offset as u32, signed) + }) + }) + .collect::>>() + .map(|prints| (base_ms, prints)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/tape/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tape/tests.rs new file mode 100644 index 00000000..d7b212f2 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tape/tests.rs @@ -0,0 +1,78 @@ +use super::*; + +fn tick(time_ms: f64, price: f32, qty: f32, side: Side) -> Tick { + Tick { + time_ms, + price, + qty, + side, + } +} + +/// What the model reads of a print — time, price, side — comes back unchanged. +fn as_read(t: &Tick) -> (f64, f32, bool) { + (t.time_ms, t.price, matches!(t.side, Side::Sell)) +} + +#[test] +fn a_tape_packs_to_eight_bytes_a_print_and_comes_back_as_the_model_reads_it() { + let ticks = vec![ + tick(1_790_129_435_428.0, 2.799, 12.0, Side::Sell), + tick(1_790_129_435_428.0, 2.799, 3.0, Side::Buy), + tick(1_790_129_445_561.0, 2.580_848_7, 7.5, Side::Sell), + tick(1_790_129_805_561.0, 0.000_012_34, 1.0, Side::Buy), + ]; + let tape = PackedTape::pack(ticks.clone()); + assert!(matches!(tape, PackedTape::Packed { .. })); + assert_eq!(tape.len(), 4); + assert_eq!(tape.bytes(), 4 * 8); + let back = tape.unpack(); + assert_eq!( + back.iter().map(as_read).collect::>(), + ticks.iter().map(as_read).collect::>() + ); + assert!( + back.iter().all(|t| t.qty == 0.0), + "the quantity is not kept" + ); +} + +#[test] +fn a_tape_that_would_lose_something_is_kept_as_it_came() { + let plain = |ticks: Vec| { + let tape = PackedTape::pack(ticks.clone()); + assert!(matches!(tape, PackedTape::Plain(_)), "{ticks:?}"); + assert_eq!(tape.bytes(), ticks.len() * std::mem::size_of::()); + let full = |t: &Tick| (as_read(t), t.qty); + assert_eq!( + tape.unpack().iter().map(full).collect::>(), + ticks.iter().map(full).collect::>() + ); + }; + // A fractional millisecond. + plain(vec![ + tick(1_000.0, 1.0, 1.0, Side::Buy), + tick(1_000.5, 1.0, 1.0, Side::Buy), + ]); + // A span past a u32 of milliseconds. + plain(vec![ + tick(0.0, 1.0, 1.0, Side::Buy), + tick(f64::from(u32::MAX) + 1.0, 1.0, 1.0, Side::Buy), + ]); + // A print before the first: its offset would be negative. (Order past the first print is + // kept as it came either way; the worker answers sorted.) + plain(vec![ + tick(2_000.0, 1.0, 1.0, Side::Buy), + tick(1_000.0, 1.0, 1.0, Side::Buy), + ]); + // A price the sign bit cannot carry. + plain(vec![tick(1_000.0, 0.0, 1.0, Side::Sell)]); +} + +#[test] +fn an_empty_tape_holds_nothing() { + let tape = PackedTape::pack(Vec::new()); + assert_eq!(tape.len(), 0); + assert_eq!(tape.bytes(), 0); + assert!(tape.unpack().is_empty()); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs index 84775ca6..6d9130ab 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs @@ -19,7 +19,7 @@ use gpui::*; use moon_chart::frozen_overlay::{OverlayBand, OverlayTrade}; use moon_chart::layers::{SEG_PATTERN_DASH, SEG_PATTERN_DOT}; use moon_core::db::tuner::ticks::ExitKind; -use moon_core::db::tuner::ticks::search::{PreparedDeal, clip_to_horizon, variant_picture}; +use moon_core::db::tuner::ticks::search::{clip_to_horizon, variant_picture}; use moon_ui::{MoonPalette, h_flex, v_flex}; use rust_i18n::t; @@ -149,23 +149,13 @@ impl AnalyticsView { if changes.iter().all(Vec::is_empty) { return None; } - let mut deal = PreparedDeal { - deal: row.deal.clone(), - ticks: row.ticks.clone()?, - entry_line: row.entry_line.clone(), - trail_ms: row.held.map(|(_, trail)| trail).unwrap_or(0), - }; - // The columns' own cut, so the picture shows what the column counted. - if let Some(horizon_ms) = data.exit_horizon_ms() { - clip_to_horizon(std::slice::from_mut(&mut deal), horizon_ms); - } Some(( - deal, - super::variants::base_of(&data.now), + data.prepared(row)?, + data.exit_horizon_ms(), data.single_kind().unwrap_or_default().to_string(), )) }); - let Some((deal, base, kind)) = job else { + let Some((pending, horizon_ms, kind)) = job else { view.update(cx, |view, cx| { view.set_model_trades(Vec::new(), Vec::new(), cx) }); @@ -173,17 +163,22 @@ impl AnalyticsView { }; let defaults = self.filter_defaults(cx); let model = super::model_cfg::current(); - let is_short = deal.deal.is_short; + let is_short = pending.deal.is_short; cx.spawn(async move |this, cx| { let executor = cx.update(|cx| cx.background_executor().clone()); let pictures = executor .spawn(async move { + // Unpacked here, off the UI thread, and cut as the columns cut it, so the + // picture shows what the column counted. + let mut deal = pending.prepare(); + if let Some(horizon_ms) = horizon_ms { + clip_to_horizon(std::slice::from_mut(&mut deal), horizon_ms); + } changes .iter() .map(|values| { - (!values.is_empty()).then(|| { - variant_picture(&deal, &base, &defaults, &kind, values, model) - }) + (!values.is_empty()) + .then(|| variant_picture(&deal, &defaults, &kind, values, model)) }) .collect::>() }) diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index d2ecc4cb..384c120a 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -6,6 +6,10 @@ //! (`TicksData::replayable`) — so the columns describe the SAME subset the "Fact · fit" column //! describes, never the whole scope. The captions say "by N" for that reason. //! +//! A variant's values are laid over each deal's OWN strategy as it stands now +//! (`TicksData::own`), not over what the selected strategies agree on: a field they disagree on +//! and the variant leaves alone runs every deal at its strategy's value. +//! //! A replay leans on what each trade's own record proves wherever a variant keeps the trade's //! own settings: the entry fills where the report says, and the stop fires when and where the //! core's did (`record::StopAnchor`) — the book the tape does not carry, answered by the fact. @@ -19,13 +23,13 @@ use rust_i18n::t; use super::super::super::AnalyticsView; use super::super::shared::N_VAR; use super::state::{NowValue, SuggState}; +use super::tape::{PendingDeal, prepare_sample}; use crate::analytics::bg::ReadLane; use moon_core::db::tuner::threshold_search::SearchHandle; use moon_core::db::tuner::ticks::TICK_PARAMS; use moon_core::db::tuner::ticks::params::ParamGroup; use moon_core::db::tuner::ticks::search::{ - DEFAULT_MAX_PASSES, PreparedDeal, SearchParams, clip_to_horizon, common_horizon_ms, suggest, - variant_tally_by_deal, + DEFAULT_MAX_PASSES, SearchParams, suggest, train_len, variant_tally_by_deal, }; use moon_core::db::tuner::ticks::stats_of; @@ -53,42 +57,16 @@ pub(super) fn passes_of(text: &str) -> usize { .clamp(1, 1_000) } -/// The base every variant is laid over: the fields the selected strategies agree on. -pub(super) fn base_of(now: &HashMap) -> HashMap { - now.iter() - .filter_map(|(key, value)| match value { - NowValue::Same(v) if !v.is_empty() => Some((key.clone(), v.clone())), - _ => None, - }) - .collect() -} - impl AnalyticsView { - /// The replayable rows as the search and the columns take them — every tape cut at the - /// sample's one exit horizon (`clip_to_horizon`), so no variant is judged on more tape - /// than another. - fn prepared_deals(&self) -> Vec { - let mut deals: Vec = self - .ticks + /// The replayable rows as the search and the columns take them, their tapes still packed: + /// the caller hands them to [`prepare_sample`] off the UI thread, which unpacks them and + /// cuts every tape at the sample's one exit horizon. + fn prepared_deals(&self) -> Vec { + self.ticks .data .data() - .map(|d| { - d.replayable() - .filter_map(|row| { - Some(PreparedDeal { - deal: row.deal.clone(), - ticks: row.ticks.clone()?, - entry_line: row.entry_line.clone(), - trail_ms: row.held.map(|(_, trail)| trail).unwrap_or(0), - }) - }) - .collect() - }) - .unwrap_or_default(); - if let Some(horizon_ms) = common_horizon_ms(&deals) { - clip_to_horizon(&mut deals, horizon_ms); - } - deals + .map(|d| d.replayable().filter_map(|row| d.prepared(row)).collect()) + .unwrap_or_default() } /// Arm a debounced rescore of the variant columns — every edit of a cell, every row that @@ -121,22 +99,22 @@ impl AnalyticsView { /// Score every touched variant over the replayable rows. fn run_ticks_variants(&mut self, req: u64, cx: &mut Context) { - let deals = self.prepared_deals(); + let pending = self.prepared_deals(); let Some(data) = self.ticks.data.data() else { return; }; - let base = base_of(&data.now); let kind = data.single_kind().unwrap_or_default().to_string(); let changes: Vec> = (0..N_VAR).map(|i| self.ticks.variant_changes(i)).collect(); let defaults = self.filter_defaults(cx); let model = super::model_cfg::current(); - let n = deals.len(); + let n = pending.len(); self.spawn_latest_db( &[ReadLane::TicksVariants], false, cx, move || { + let deals = prepare_sample(pending); changes .iter() .map(|values| { @@ -144,7 +122,7 @@ impl AnalyticsView { return None; } let (tally, spent, money) = - variant_tally_by_deal(&deals, &base, &defaults, &kind, values, model); + variant_tally_by_deal(&deals, &defaults, &kind, values, model); let plan: HashMap = money .into_iter() .filter_map(|(uid, value)| Some((uid, value?))) @@ -275,7 +253,7 @@ impl AnalyticsView { if matches!(self.ticks.sugg, SuggState::Running { .. }) { return; } - let deals = self.prepared_deals(); + let pending = self.prepared_deals(); let Some(data) = self.ticks.data.data() else { return; }; @@ -283,7 +261,9 @@ impl AnalyticsView { return self.ticks_search_refused("analytics.ticks.sugg_one_kind", cx); }; let model = super::model_cfg::current(); - let mut base = base_of(&data.now); + // Laid over every deal's own strategy before the search's point: nothing for a search + // of every field, В1's other fields for a search of one. + let mut held: HashMap = HashMap::new(); let (vary_entry, vary_exit, locked) = match only { None => ( self.ticks_group_searchable(ParamGroup::Entry), @@ -302,9 +282,7 @@ impl AnalyticsView { } // The other fields as В1 has them: the one field is searched in the variant it // will land in, not in the strategy as it stands. - for (k, v) in self.ticks.variant_changes(0) { - base.insert(k, v); - } + held.extend(self.ticks.variant_changes(0)); let locked: HashSet = TICK_PARAMS .iter() .map(|f| f.key) @@ -321,7 +299,7 @@ impl AnalyticsView { if !(vary_entry || vary_exit) { return self.ticks_search_refused("analytics.ticks.sugg_nothing", cx); } - if deals.is_empty() { + if pending.is_empty() { return self.ticks_search_refused("analytics.ticks.sugg_no_tape", cx); } let defaults = self.filter_defaults(cx); @@ -336,6 +314,16 @@ impl AnalyticsView { .ok() .filter(|n| *n > 0); let train_frac = super::super::filter::state::train_frac(self.ticks.train_pct); + // A floor over the slice the search fits on no point can keep: say so before a run that + // can only come back empty. + let closes: Vec = pending.iter().map(|d| d.deal.close_ms).collect(); + let train_n = train_len(&closes, train_frac); + if let Some(n) = min_n.filter(|n| *n > train_n as i64) { + self.ticks.sugg_note = + Some(t!("analytics.ticks.sugg_floor_sample", n = n, m = train_n).to_string()); + cx.notify(); + return; + } let handle = SearchHandle::new(); self.ticks.sugg = SuggState::Running { handle: handle.clone(), @@ -349,8 +337,9 @@ impl AnalyticsView { false, cx, move || { + let deals = prepare_sample(pending); let params = SearchParams { - base: &base, + held: &held, defaults: &defaults, kind: &kind, vary_entry, @@ -392,8 +381,12 @@ impl AnalyticsView { this.ticks.last_result = Some(result); this.arm_ticks_variants(cx); } + // Under a floor, "nothing" is that no point kept it. None => { - this.ticks.sugg_note = Some(t!("analytics.ticks.sugg_none").to_string()); + this.ticks.sugg_note = Some(match min_n { + Some(n) => t!("analytics.ticks.sugg_floor", n = n).to_string(), + None => t!("analytics.ticks.sugg_none").to_string(), + }); } } cx.notify(); @@ -438,27 +431,34 @@ impl AnalyticsView { } /// The honesty line of a write: a closer `MShotPrice` is UNDERESTIMATED by the sample - /// (spikes the real order never reached are not in the report), so the dialog says so. + /// (spikes the real order never reached are not in the report), so the dialog says so — + /// when the value is closer than ANY strategy's own, not only when they all agree on one. fn ticks_change_warnings(&self, changes: &[(String, String)]) -> Vec { let mut warns = Vec::new(); - let base_price = self - .ticks - .data - .data() - .and_then(|d| match d.now.get("MShotPrice") { - Some(NowValue::Same(v)) => v.replace(',', ".").parse::().ok(), + let parse = |v: &str| v.replace(',', ".").parse::().ok(); + let Some(value) = changes + .iter() + .find(|(k, _)| k == "MShotPrice") + .and_then(|(_, v)| parse(v)) + else { + return warns; + }; + let closer = self.ticks.data.data().is_some_and(|d| { + let agreed = match d.now.get("MShotPrice") { + Some(NowValue::Same(v)) => parse(v), _ => None, - }); - if let (Some(base), Some((_, value))) = - (base_price, changes.iter().find(|(k, _)| k == "MShotPrice")) - { - if value - .replace(',', ".") - .parse::() - .is_ok_and(|v| v < base) - { - warns.push(t!("analytics.ticks.closer_warn").to_string()); - } + }; + agreed + .into_iter() + .chain( + d.own + .values() + .filter_map(|own| own.get("MShotPrice").and_then(|v| parse(v))), + ) + .any(|base| value < base) + }); + if closer { + warns.push(t!("analytics.ticks.closer_warn").to_string()); } warns } diff --git a/locales/analytics.yml b/locales/analytics.yml index 68192fd2..359b8a37 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1960,6 +1960,14 @@ analytics.ticks.sugg_none: ru: "Подбор ничего не нашёл" en: "The search found nothing" es: "La búsqueda no encontró nada" +analytics.ticks.sugg_floor: + ru: "Ни один вариант не держит ≥ %{n} сделок" + en: "No variant keeps ≥ %{n} trades" + es: "Ninguna variante mantiene ≥ %{n} operaciones" +analytics.ticks.sugg_floor_sample: + ru: "Порог %{n} сделок больше выборки: в обучении %{m} сделок с лентой в памяти" + en: "A floor of %{n} trades exceeds the sample: %{m} trades with tape in memory to train on" + es: "Un mínimo de %{n} operaciones supera la muestra: %{m} con cinta en memoria para entrenar" analytics.ticks.closer_warn: ru: "MShotPrice ближе фактического: оценка занижена — прострелы, до которых реальный ордер не дотянулся, в отчёте отсутствуют" en: "MShotPrice closer than the fact: underestimated — spikes the real order never reached are not in the report" From 282c67d7e52509f3d3da0baadd5385ca5a7a79ca Mon Sep 17 00:00:00 2001 From: guyverino Date: Thu, 24 Sep 2026 01:18:37 +0200 Subject: [PATCH 32/51] feat(tuner): keep the corridor, move distance in pairs and say how a search went MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/config/layout.rs | 4 + crates/moon-core/src/config/layout/tests.rs | 11 + crates/moon-core/src/db/tuner/ticks/mod.rs | 26 +- crates/moon-core/src/db/tuner/ticks/mshot.rs | 36 ++ crates/moon-core/src/db/tuner/ticks/params.rs | 13 +- crates/moon-core/src/db/tuner/ticks/search.rs | 586 +++++++++++++++--- .../src/db/tuner/ticks/search/tests.rs | 271 +++++++- .../src/analytics/tuner/ticks/cfg.rs | 53 +- .../src/analytics/tuner/ticks/rows/tests.rs | 1 + .../src/analytics/tuner/ticks/state.rs | 6 + .../src/analytics/tuner/ticks/variants.rs | 119 ++-- locales/analytics.yml | 34 +- 12 files changed, 1031 insertions(+), 129 deletions(-) diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index 55946eb8..a7734044 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -647,6 +647,10 @@ pub struct TicksAxisLayout { pub model: crate::db::tuner::ticks::ModelSettings, /// Whether the trade pane under the deal table is open. pub trade_open: bool, + /// Whether the search may bring a trade's entry corridor nearer the price than the trade's + /// own. Off by default — and stored this way round so that a config written before the + /// switch existed reads it off, the guard on (`SearchParams::keep_corridor`). + pub allow_closer_corridor: bool, } /// Complete window layout. diff --git a/crates/moon-core/src/config/layout/tests.rs b/crates/moon-core/src/config/layout/tests.rs index d0caa355..55212fa2 100644 --- a/crates/moon-core/src/config/layout/tests.rs +++ b/crates/moon-core/src/config/layout/tests.rs @@ -2328,6 +2328,7 @@ fn the_ticks_axis_settings_round_trip_and_never_cost_the_layout() { iters: Some(40), locked: vec!["SellPrice".to_string()], trade_open: true, + allow_closer_corridor: true, model: crate::db::tuner::ticks::ModelSettings { latency_ms: 250.0, entry_method: crate::db::tuner::ticks::EntryMethod::Shift, @@ -2344,6 +2345,16 @@ fn the_ticks_axis_settings_round_trip_and_never_cost_the_layout() { let old: WindowLayout = toml::from_str("analytics_period = \"p-cur-month\"\n").unwrap(); assert_eq!(old.analytics_ticks, None); + // A block written before the corridor switch existed keeps the guard on. + let before: WindowLayout = + toml::from_str("[analytics_ticks]\niters = 40\n").expect("a block without the switch"); + assert!( + !before + .analytics_ticks + .expect("the block") + .allow_closer_corridor + ); + let partial: WindowLayout = toml::from_str("[analytics_ticks.model]\nlatency_ms = 150.0\n").expect("a partial block"); let model = partial.analytics_ticks.expect("the block").model; diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index c8833052..7cf959e9 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -49,7 +49,9 @@ pub use mshot::{CorridorStep, EntryMethod, MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; pub use record::{OwnLines, StopAnchor, entry_placement, fit_for_search, prepare_deal}; pub use scope::{is_service_row, is_tunable}; -pub use search::{PreparedDeal, SearchParams, SearchResult, suggest, variant_tally}; +pub use search::{ + PreparedDeal, SearchMiss, SearchParams, SearchResult, SearchStats, suggest, variant_tally, +}; pub use settings::ModelSettings; pub use stats::{fact_stats, stats_of}; pub use verify::{Verdict, verify}; @@ -274,6 +276,28 @@ impl Deal { order_open_at(self.buy_ms, self.buy_set_ms) } + /// The deltas the entry order lived through, one per refresh step of the live track + /// ([`deltas::STEP_MS`]) from the order's creation ([`Deal::order_open_ms`]) to the fill, + /// the fill's own included — the core re-places the corridor when a delta moves, so these + /// are every corridor it held as the model reads them: where the track does not reach, the + /// report's snapshot, as `deltas_at` gives it to the replay itself. A deal without a track + /// has the snapshot alone, and + /// one whose order waited past [`ORDER_WAIT_CAP_MS`] the fill's alone: its early life is + /// neither fetched nor replayed, so nothing of it is held against a variant either. + pub fn entry_deltas(&self) -> Vec { + if self.delta_track.is_none() { + return vec![self.deltas]; + } + let to = self.buy_ms; + let from = self.order_open_ms().unwrap_or(to); + let mut out: Vec = (from..to) + .step_by(deltas::STEP_MS as usize) + .map(|t| self.deltas_at(t)) + .collect(); + out.push(self.deltas_at(to)); + out + } + /// The deltas as the core held them at a moment: every field the live track answers for /// there, where the deal has one, the report's snapshot for everything else. pub fn deltas_at(&self, t_ms: i64) -> Deltas { diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index 32c0db4f..b486aacb 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -302,8 +302,44 @@ impl MshotParams { let far = (self.price_pct + self.modifiers.far_addition(deltas)).max(near); (near, far) } + + /// Whether the near bound sits below the far one — `MShotPriceMin < MShotPrice`, the + /// corridor the fields describe: the price moves above the order between the two (FAQ: 10 % + /// and 7 %, the price between +7 and +10 %). A pair at or past that is a corridor no field + /// describes; the model reads it as a band of no width (`bounds_pct` lifts the far bound to + /// the near one), and what the core does with it is not known — a variant built on it is + /// judged on a regime that may not exist. + pub fn is_ordered(&self) -> bool { + self.price_min_pct < self.price_pct + } + + /// Whether this corridor stands at least as far from the price as `own` at every one of + /// `deltas` — both bounds, under the same deltas. The corridor is what the delta modifiers + /// make of the base fields, so a variant may move its distance between `MShotPrice` and the + /// `MShotAdd*` fields, but never end up nearer the price than the trade did: an order nearer + /// the price fills on spikes the real one never reached, and those trades are not in the + /// sample the variant is judged on. + /// + /// Args: + /// own: The corridor the trade ran with. + /// deltas: The deltas its entry order lived through ([`Deal::entry_deltas`]). + /// near_too: Whether the near bound is held as well as the far one — not under + /// [`EntryMethod::Shift`], which places the order at the far bound and reads nothing + /// of the near one. + pub fn never_closer_than(&self, own: &MshotParams, deltas: &[Deltas], near_too: bool) -> bool { + deltas.iter().all(|d| { + let (near, far) = self.bounds_pct(d); + let (own_near, own_far) = own.bounds_pct(d); + far >= own_far - CORRIDOR_EPS_PCT && (!near_too || near >= own_near - CORRIDOR_EPS_PCT) + }) + } } +/// How far below the fact's bound, in per cent, a variant's bound may sit and still count as +/// not nearer: the same corridor arrived at through a different sum of modifiers differs only +/// by float rounding. +const CORRIDOR_EPS_PCT: f64 = 1e-9; + /// One placement of a modelled order and the corridor around it, until the next placement. #[derive(Clone, Copy, Debug, PartialEq)] pub struct CorridorStep { diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 0c81d4f0..be0b96d8 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -71,10 +71,19 @@ const GRID_ADJUST: &[f64] = &[ -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, ]; +/// The `MShotAdd*` modifiers, per cent per one per cent of the delta. Fine near zero — the +/// 24-hour deltas run to tens of per cent, and their live coefficients sit at 0.001–0.002 — then +/// a 0.05 step to 1.0 (the developer's call, 2026-09-24): the old 0.2 ceiling could not reach a +/// live `MShotAddMarkDelta` of 0.5. const GRID_ADD: &[f64] = &[ - 0.0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.12, 0.14, 0.16, 0.18, 0.2, + 0.0, 0.001, 0.002, 0.005, 0.01, 0.02, 0.03, 0.04, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, + 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0, +]; +/// `MShotAddDistance`, per cent: finer below 50 — a live strategy's 10 sat between the old 0 +/// and 25 (2026-09-24). +const GRID_DISTANCE: &[f64] = &[ + 0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0, 75.0, 100.0, 150.0, 200.0, ]; -const GRID_DISTANCE: &[f64] = &[0.0, 25.0, 50.0, 100.0, 200.0]; /// Measured against the 1 713 live strategies that set it (2026-09-22): median 1 %, and 300 of /// them sit outside 0.2…5 — up to 11 % — so the tail is covered rather than clipped. const GRID_SELL_PRICE: &[f64] = &[ diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index d4df2935..ad3acc4c 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -1,6 +1,10 @@ -//! The search of the "Entry/Exit" axis: coordinate descent with random restarts over the -//! discrete grids of [`TICK_PARAMS`], scoring a point by REPLAYING every covered deal under -//! it — the same shape as `threshold_search`, with the SQL mask replaced by [`simulate`]. +//! The search of the "Entry/Exit" axis: coordinate descent with restarts over the discrete +//! grids of [`TICK_PARAMS`], scoring a point by REPLAYING every covered deal under it — the shape +//! of `threshold_search`, with the SQL mask replaced by [`simulate`]. Restart 0 starts from the +//! strategy itself; the others from the strategy moved a few steps on a few fields, each walking +//! the fields in an order of its own. A pass that moves no single field then tries PAIRS of the +//! Entry group's number fields, one a step down and another a step up, so a corridor's distance +//! can move between the base fields and the modifiers ([`descend`]). //! //! A point is a set of strategy values in the strategy's own spelling, laid over the values //! each deal's OWN strategy holds now ([`PreparedDeal::own`]) — a field the point leaves alone @@ -30,12 +34,12 @@ use std::sync::Arc; use rayon::prelude::*; -use super::mshot::{CorridorStep, EntryMethod, MshotEntry}; +use super::mshot::{CorridorStep, EntryMethod, MshotEntry, MshotParams}; use super::params::{ - ParamGroup, ParamKind, StrategyValues, TICK_PARAMS, exit_params, mshot_params, + ParamGroup, ParamKind, StrategyValues, TICK_PARAMS, TickParam, exit_params, mshot_params, }; use super::settings::ModelSettings; -use super::{Deal, EntryParams, ExitParams, Outcome, entry_model_for, simulate}; +use super::{Deal, Deltas, EntryParams, ExitParams, Outcome, entry_model_for, simulate}; use crate::db::metrics::Tally; use crate::db::tuner::threshold_search::search::{install, restart_seed}; use crate::db::tuner::threshold_search::{SearchHandle, train_split}; @@ -128,6 +132,47 @@ pub struct SearchParams<'a> { pub max_passes: usize, /// The model's own settings, the entry method among them. pub model: ModelSettings, + /// Whether a point whose entry corridor comes nearer the price than a trade's own, at any + /// moment of that trade's entry order, is out of the search + /// ([`MshotParams::never_closer_than`]). Read only while the Entry group is searched: a + /// search of the exit alone moves no corridor. + pub keep_corridor: bool, +} + +/// Why a search came back with nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SearchMiss { + /// Nothing to search — no deals, no field to vary — or the run was stopped before its + /// first restart finished. + Nothing, + /// No point the search visited kept `min_n` trades. + Floor, + /// No point the search visited kept a corridor it may propose: `MShotPriceMin` below + /// `MShotPrice` where the strategy had them so ([`MshotParams::is_ordered`]), and, under + /// [`SearchParams::keep_corridor`], every trade's corridor at least as far from the price as + /// the trade's own. + Corridor, +} + +/// How a search went — what shows whether its restarts and passes changed anything. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SearchStats { + /// Restarts that ran to the end (a stop leaves the rest out). + pub restarts: usize, + /// The restart the answer came from: 0 starts from the strategy itself. + pub best_restart: usize, + /// Passes of coordinate descent the winning restart took. + pub passes: usize, + /// Whether the winning restart stopped because a pass changed nothing — `false` means it + /// was still improving when the pass limit cut it. + pub converged: bool, + /// Distinct end points among the restarts: 1 means every restart ended at the same point. + pub distinct: usize, + /// Points scored over the whole run, each a replay of the training slice. + pub evaluations: usize, + /// Restarts that ended on a point the corridor rules refuse — none of their moves reached + /// an allowed one. + pub refused: usize, } /// What the search found. @@ -142,6 +187,198 @@ pub struct SearchResult { pub holdout: Option, /// The seed the restarts were derived from. pub seed: u64, + /// How the run went. + pub stats: SearchStats, +} + +/// Where one descent stopped. +struct Walked { + point: Point, + score: Option, + passes: usize, + converged: bool, +} + +/// One restart's descent from `point`: every pass visits each field in `order` over its whole +/// grid, keeping any value that beats the score; a pass that moves no single field then tries +/// the pairs — one field of `pairs` a step down, another a step up — so a distance shared +/// between two fields can move from one to the other, which no single move reaches when each +/// alone makes the score worse or breaks a corridor rule. The walk ends when a pass moves +/// nothing, or at `max_passes`. +/// +/// Returns: +/// Where it stopped, or `None` when the run was stopped. +#[allow(clippy::too_many_arguments)] +fn descend( + mut point: Point, + order: &[&'static TickParam], + pairs: &[&'static TickParam], + start: &HashMap<&'static str, usize>, + evaluate: &(dyn Fn(&Point) -> Option + Sync), + min_n: i64, + max_passes: usize, + handle: &SearchHandle, +) -> Option { + let mut score = evaluate(&point); + let (mut passes, mut converged) = (0, false); + for _ in 0..max_passes { + passes += 1; + let mut improved = false; + for field in order { + if handle.is_cancelled() { + handle.note_abandoned(); + return None; + } + let mut current = point.get(field.key).cloned(); + for index in 0..arity(&field.kind) { + let candidate = spell(&field.kind, index); + if current.as_deref() == Some(candidate.as_str()) { + continue; + } + point.insert(field.key, candidate.clone()); + let trial = evaluate(&point); + if better_score(&trial, &score, min_n) { + score = trial; + improved = true; + // The accepted value is what a rejected later candidate restores to. + current = Some(candidate); + } else { + restore(&mut point, field.key, current.clone()); + } + } + // A field that moved may enable a better value of one visited before, hence the + // passes; within one pass every field is visited once. + } + if !improved { + for &down in pairs { + for &up in pairs { + // Each pair is a replay of the sample: a stop is noticed between two of + // them, not after a whole row. + if handle.is_cancelled() { + handle.note_abandoned(); + return None; + } + if down.key == up.key { + continue; + } + let (Some(d), Some(u)) = ( + grid_index(down, &point, start), + grid_index(up, &point, start), + ) else { + continue; + }; + if d == 0 || u + 1 >= arity(&up.kind) { + continue; + } + let (was_down, was_up) = + (point.get(down.key).cloned(), point.get(up.key).cloned()); + point.insert(down.key, spell(&down.kind, d - 1)); + point.insert(up.key, spell(&up.kind, u + 1)); + let trial = evaluate(&point); + if better_score(&trial, &score, min_n) { + score = trial; + improved = true; + } else { + restore(&mut point, down.key, was_down); + restore(&mut point, up.key, was_up); + } + } + } + } + if !improved { + converged = true; + break; + } + } + Some(Walked { + point, + score, + passes, + converged, + }) +} + +/// Put a field back to what the point held: a value, or none (the base's). +fn restore(point: &mut Point, key: &'static str, was: Option) { + match was { + Some(value) => { + point.insert(key, value); + } + None => { + point.remove(key); + } + } +} + +/// Where a number field stands on its grid: the step the point holds, else the base's +/// (`start`). `None` for a field that is not a number, or a base with no value to snap. +fn grid_index( + field: &TickParam, + point: &Point, + start: &HashMap<&'static str, usize>, +) -> Option { + let ParamKind::Num { .. } = &field.kind else { + return None; + }; + match point.get(field.key) { + Some(value) => (0..arity(&field.kind)).find(|&i| spell(&field.kind, i) == *value), + None => start.get(field.key).copied(), + } +} + +/// The grid step nearest `value`. +fn nearest_step(grid: &[f64], value: f64) -> usize { + grid.iter() + .enumerate() + .min_by(|(_, a), (_, b)| (*a - value).abs().total_cmp(&(*b - value).abs())) + .map_or(0, |(i, _)| i) +} + +/// Fisher–Yates over the restart's own stream. +fn shuffle(items: &mut [T], state: &mut u64) { + for i in (1..items.len()).rev() { + let j = (next_random(state) % (i as u64 + 1)) as usize; + items.swap(i, j); + } +} + +/// Move the base a little: one to three fields of `order`, a number field one to three grid +/// steps either way from where it stands (`start`), any other field to a value of its own. +fn perturb( + point: &mut Point, + order: &[&'static TickParam], + start: &HashMap<&'static str, usize>, + state: &mut u64, +) { + if order.is_empty() { + return; + } + let moves = 1 + (next_random(state) % 3) as usize; + for _ in 0..moves { + let field = order[(next_random(state) % order.len() as u64) as usize]; + let n = arity(&field.kind); + let index = match (&field.kind, start.get(field.key)) { + (ParamKind::Num { .. }, Some(&at)) => { + let step = 1 + (next_random(state) % 3) as usize; + if next_random(state) % 2 == 0 { + at.saturating_sub(step) + } else { + (at + step).min(n - 1) + } + } + _ => (next_random(state) % n as u64) as usize, + }; + point.insert(field.key, spell(&field.kind, index)); + } +} + +/// One restart's end: where the descent stopped and how it got there. +struct Run { + restart: usize, + point: Point, + score: Option, + passes: usize, + converged: bool, } /// One point of the grid: the varied fields' values, in strategy spelling. @@ -312,6 +549,128 @@ fn tally_and_spent( (tally, spent) } +/// Each MoonShot deal's own corridor and the deltas its entry order lived through — what a +/// point's corridor is held against under [`SearchParams::keep_corridor`]. Read once per search: +/// the deltas along a track are the same for every point. +struct CorridorGuard { + /// `(deal index, own corridor, deltas)`; a deal without a MoonShot entry of its own holds + /// no corridor to keep. + deals: Vec<(usize, MshotParams, Vec)>, +} + +impl CorridorGuard { + fn of(deals: &[PreparedDeal]) -> Self { + Self { + deals: deals + .iter() + .enumerate() + .filter_map(|(i, d)| match &d.deal.own_entry { + Some(EntryParams::MoonShot(own)) => { + Some((i, own.clone(), d.deal.entry_deltas())) + } + _ => None, + }) + .collect(), + } + } + + /// Whether a point's parameters keep every deal's corridor. + /// + /// Args: + /// of_deal: Each deal's index into `params` ([`Bases::of_deal`]). + /// params: The point's parameters per base ([`Bases::params`]). + fn holds(&self, of_deal: &[usize], params: &[(EntryParams, ExitParams)]) -> bool { + self.deals + .iter() + .all(|(i, own, deltas)| keeps_corridor(¶ms[of_deal[*i]].0, own, deltas)) + } +} + +/// Whether an entry keeps a trade's own corridor ([`MshotParams::never_closer_than`]); an entry +/// of the fact's has none to move. The near bound is held only where the entry method reads it. +fn keeps_corridor(entry: &EntryParams, own: &MshotParams, deltas: &[Deltas]) -> bool { + match entry { + EntryParams::MoonShot(variant) => { + let near_too = variant.model.entry_method != EntryMethod::Shift; + variant.never_closer_than(own, deltas, near_too) + } + EntryParams::Fact => true, + } +} + +/// Whether an entry's corridor fields are in order ([`MshotParams::is_ordered`]); an entry of the +/// fact's has none. +fn ordered(entry: &EntryParams) -> bool { + match entry { + EntryParams::MoonShot(params) => params.is_ordered(), + EntryParams::Fact => true, + } +} + +/// Whether a point's parameters invert the corridor fields of a base that started in order +/// (`start_ordered`, one flag per base, in the bases' order). +fn inverts(start_ordered: &[bool], params: &[(EntryParams, ExitParams)]) -> bool { + start_ordered + .iter() + .zip(params) + .any(|(was, (entry, _))| *was && !ordered(entry)) +} + +/// What [`check_corridors`] finds of one variant over a sample. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CorridorCheck { + /// Deals whose corridor the variant brings nearer the price than their own. + pub nearer: usize, + /// Deals whose corridor fields the variant inverts (`MShotPriceMin ≥ MShotPrice`). + pub inverted: usize, + /// Deals with a MoonShot corridor of their own to hold. + pub checked: usize, +} + +/// The corridor rules the search keeps, asked of a variant the search did not make — one typed +/// into a column, before it is written: on how many deals it comes nearer the price than their +/// own corridor ([`SearchParams::keep_corridor`]), and on how many it inverts the corridor's two +/// fields ([`MshotParams::is_ordered`]). +/// +/// Args: +/// deals: Each deal with its strategy's current values ([`PreparedDeal::own`]). +/// defaults, values, model: As for [`variant_tally`]; the kind is each deal's own. +pub fn check_corridors<'a>( + deals: impl IntoIterator)>, + defaults: &HashMap, + values: &[(String, String)], + model: ModelSettings, +) -> CorridorCheck { + let point = point_of(values); + let model = model.sanitized(); + let held = HashMap::new(); + let mut out = CorridorCheck::default(); + for (deal, own_base) in deals { + let Some(EntryParams::MoonShot(own)) = &deal.own_entry else { + continue; + }; + let (entry, _) = params_of(own_base, &held, defaults, &point, &deal.kind, model); + out.checked += 1; + if !keeps_corridor(&entry, own, &deal.entry_deltas()) { + out.nearer += 1; + } + if !ordered(&entry) { + out.inverted += 1; + } + } + out +} + +/// Whether `a` beats `b` where a point may be out of the search: `None` is a point the corridor +/// guard refused, below every point it let through. +fn better_score(a: &Option, b: &Option, min_n: i64) -> bool { + match (a, b) { + (Some(a), Some(b)) => better(a, b, min_n), + (Some(_), None) => true, + (None, _) => false, + } +} + /// The tally of a point over `deals`, in order; arguments as for [`results`]. fn tally(deals: &[PreparedDeal], of_deal: &[usize], params: &[(EntryParams, ExitParams)]) -> Tally { tally_and_spent(deals, of_deal, params).0 @@ -359,17 +718,17 @@ pub fn train_len(closes: &[i64], train_frac: f64) -> usize { /// handle: Stop and progress; a fresh one per run. /// /// Returns: -/// The best point found, or `None` when the sample is empty, nothing is varied, the run -/// was stopped before its first restart finished, or no point it visited keeps `min_n` -/// trades — the richest point under the floor is not what the caller asked for. +/// The best point found, or why there is none ([`SearchMiss`]): nothing to search or a +/// stop, no point that keeps `min_n` trades — the richest point under the floor is not what +/// the caller asked for — or none that keeps the corridor. pub fn suggest( deals: &[PreparedDeal], params: &SearchParams<'_>, handle: &SearchHandle, -) -> Option { +) -> Result { let fields = varied(params); if deals.is_empty() || fields.is_empty() { - return None; + return Err(SearchMiss::Nothing); } let closes: Vec = deals.iter().map(|d| d.deal.close_ms).collect(); let train_n = train_len(&closes, params.train_frac); @@ -390,11 +749,82 @@ pub fn suggest( let model = params.model.sanitized(); let bases = Bases::of(deals); let train_of = &bases.of_deal[..train_n]; - let evaluate = |point: &Point| -> Tally { + // Held over the whole sample, the holdout included: a corridor nearer the price than a + // trade's own is out whichever side of the cut the trade sits on. + let guard = (params.keep_corridor && params.vary_entry).then(|| CorridorGuard::of(deals)); + // Which bases start with their corridor fields in order: the search must not invert one + // that is, and must not be held hostage by one that already is — a strategy stored inverted + // would otherwise refuse every point of a search that never touches its corridor. The + // write warns about that one (`check_corridors`). + let start_ordered: Vec = bases + .params( + params.held, + params.defaults, + &Point::new(), + params.kind, + model, + ) + .iter() + .map(|(entry, _)| ordered(entry)) + .collect(); + let evaluations = std::sync::atomic::AtomicUsize::new(0); + let evaluate = |point: &Point| -> Option { let per_base = bases.params(params.held, params.defaults, point, params.kind, model); - tally(train, train_of, &per_base) + // A point that inverts the corridor's two fields is never proposed, whatever the + // switch: the searched fields are gridded one by one, and nothing else ties them. Only + // a search of the Entry group can produce one — an Exit search leaves the strategy's + // own fields alone, whatever they hold. + if params.vary_entry && inverts(&start_ordered, &per_base) { + return None; + } + if guard + .as_ref() + .is_some_and(|g| !g.holds(&bases.of_deal, &per_base)) + { + return None; + } + // Counted here, past the refusals: what the stats call a scored point is a replay. + evaluations.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Some(tally(train, train_of, &per_base)) }; - let best = install(|| { + // Where each number field starts on its grid — the median of what the selected strategies + // hold (the held value over all of them; the schema default for one that leaves it out), + // snapped to the nearest grid step: what a pair move and a perturbed start step from while + // the point leaves the field alone. A move sets one value for every strategy, so it steps + // from the middle of theirs rather than from whichever came first. + let parse = |text: &String| text.trim().replace(',', ".").parse::().ok(); + let start: HashMap<&'static str, usize> = fields + .iter() + .filter_map(|f| { + let ParamKind::Num { grid } = &f.kind else { + return None; + }; + let default = params.defaults.get(f.key).copied(); + let mut values: Vec = match params.held.get(f.key).and_then(parse) { + Some(held) => vec![held], + None => bases + .owns + .iter() + .filter_map(|own| own.get(f.key).and_then(parse).or(default)) + .collect(), + }; + values.sort_by(f64::total_cmp); + let value = values.get(values.len() / 2).copied().or(default)?; + Some((f.key, nearest_step(grid, value))) + }) + .collect(); + // The fields that move in pairs: the Entry group's numbers, where a corridor's distance is + // shared between the base fields and the modifiers and one field alone cannot move it. + let pairs: Vec<&'static TickParam> = if params.vary_entry { + fields + .iter() + .filter(|f| f.group == ParamGroup::Entry && matches!(f.kind, ParamKind::Num { .. })) + .copied() + .collect() + } else { + Vec::new() + }; + let runs: Vec = install(|| { (0..restarts) .into_par_iter() .map(|restart| { @@ -402,75 +832,78 @@ pub fn suggest( handle.note_abandoned(); return None; } - // Restart 0 starts from the base itself; the others from a random grid point - // per varied field, so the descent is not trapped in the base's own valley. + // Restart 0 starts from the base itself, in grid order. The others start from + // the base moved a few steps on a few fields, and walk the fields in an order of + // their own: a start anywhere on the grid lands far from anything a strategy + // would run and descends into a worse valley every time (2026-09-24: 19 of 20 + // random restarts lost to restart 0 on every run). + let mut order = fields.clone(); let mut point = Point::new(); if restart > 0 { let mut state = restart_seed(seed, restart); - for field in &fields { - let index = (next_random(&mut state) % arity(&field.kind) as u64) as usize; - point.insert(field.key, spell(&field.kind, index)); - } - } - let mut score = evaluate(&point); - for _ in 0..max_passes { - let mut improved = false; - for field in &fields { - if handle.is_cancelled() { - handle.note_abandoned(); - return None; - } - let mut current = point.get(field.key).cloned(); - for index in 0..arity(&field.kind) { - let candidate = spell(&field.kind, index); - if current.as_deref() == Some(candidate.as_str()) { - continue; - } - point.insert(field.key, candidate.clone()); - let trial = evaluate(&point); - if better(&trial, &score, min_n) { - score = trial; - improved = true; - // The accepted value is what a rejected later candidate - // restores to. - current = Some(candidate); - } else { - match ¤t { - Some(c) => { - point.insert(field.key, c.clone()); - } - None => { - point.remove(field.key); - } - } - } - } - // A field that moved may enable a better value of one visited before, - // hence the passes; within one pass every field is visited once. - } - if !improved { - break; - } + shuffle(&mut order, &mut state); + perturb(&mut point, &order, &start, &mut state); } + let walked = descend( + point, &order, &pairs, &start, &evaluate, min_n, max_passes, handle, + )?; handle.record_restart(); - Some((restart, point, score)) + Some(Run { + restart, + point: walked.point, + score: walked.score, + passes: walked.passes, + converged: walked.converged, + }) }) .flatten() - // Among equal scores the LOWEST restart wins, so the parallel fan-out answers as a - // sequential run would. - .reduce_with(|a, b| { - if better(&b.2, &a.2, min_n) || (!better(&a.2, &b.2, min_n) && b.0 < a.0) { - b - } else { - a - } - }) - })?; - let (_, point, train_tally) = best; - // `better` ranks a point under the floor below any above it, so a best under it means no - // point held the floor at all. + .collect() + }); + // Among equal scores the LOWEST restart wins, so the parallel fan-out answers as a + // sequential run would: in restart order, a later run takes the lead only by beating it. + let mut runs = runs; + runs.sort_by_key(|run| run.restart); + // An end point by the parameters it comes to on every base, not by its spelling: restart 0 + // leaves a field at its base by not holding it, a random restart by holding the base's + // value — or the schema default's, for a field the strategy leaves out — and those are one + // end point, not two. + let mut ends: Vec> = Vec::new(); + for run in &runs { + let end = bases.params(params.held, params.defaults, &run.point, params.kind, model); + if !ends.contains(&end) { + ends.push(end); + } + } + let distinct = ends.len(); + let restarts_done = runs.len(); + let refused = runs.iter().filter(|run| run.score.is_none()).count(); + let best = runs + .into_iter() + .reduce(|a, b| { + if better_score(&b.score, &a.score, min_n) { + b + } else { + a + } + }) + .ok_or(SearchMiss::Nothing)?; + let stats = SearchStats { + restarts: restarts_done, + best_restart: best.restart, + passes: best.passes, + converged: best.converged, + distinct, + evaluations: evaluations.load(std::sync::atomic::Ordering::Relaxed), + refused, + }; + let (point, score) = (best.point, best.score); + // `better_score` ranks a refused point below every other, and `better` one under the floor + // below any above it: a best refused or under the floor means no point held either. + let Some(train_tally) = score else { + return Err(SearchMiss::Corridor); + }; if train_tally.n < min_n { - return None; + return Err(SearchMiss::Floor); } // A field every deal's base already spells so is not a change; only what moved is // reported — a value one strategy holds and another does not is a change for the other. @@ -484,11 +917,12 @@ pub fn suggest( let per_base = bases.params(params.held, params.defaults, &point, params.kind, model); tally(&deals[train_n..], &bases.of_deal[train_n..], &per_base) }); - Some(SearchResult { + Ok(SearchResult { values, train: train_tally, holdout, seed, + stats, }) } diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 378d46cc..af4839be 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -117,6 +117,7 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { seed: Some(7), train_frac: 1.0, max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, model: ModelSettings { latency_ms: 0.0, ..ModelSettings::default() @@ -137,6 +138,14 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { ); assert!(result.holdout.is_none()); assert_eq!(handle.completed(), 3); + // The run's own account: every restart finished, each within the pass limit, and at least + // one point was scored per restart. + let stats = result.stats; + assert_eq!(stats.restarts, 3); + assert!(stats.best_restart < 3); + assert!(stats.converged && stats.passes >= 1 && stats.passes <= DEFAULT_MAX_PASSES); + assert!((1..=3).contains(&stats.distinct)); + assert!(stats.evaluations >= 3); // The same values through the variant column. let (tally, spent) = variant_tally( &deals, @@ -213,6 +222,7 @@ fn the_holdout_is_scored_but_never_fitted_on() { seed: Some(1), train_frac: 0.75, max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, model: ModelSettings { latency_ms: 0.0, ..ModelSettings::default() @@ -244,6 +254,7 @@ fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { seed: Some(1), train_frac: 1.0, max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, model: ModelSettings { latency_ms: 0.0, ..ModelSettings::default() @@ -251,7 +262,7 @@ fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { }; let handle = SearchHandle::new(); assert!( - suggest(&deals, ¶ms, &handle).is_none(), + suggest(&deals, ¶ms, &handle).is_err(), "everything locked" ); let none: HashSet = HashSet::new(); @@ -261,7 +272,7 @@ fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { }; let handle = SearchHandle::new(); handle.cancel(); - assert!(suggest(&deals, ¶ms, &handle).is_none()); + assert!(suggest(&deals, ¶ms, &handle).is_err()); assert!(handle.abandoned()); } @@ -344,6 +355,7 @@ fn a_shift_does_not_search_the_path_only_fields() { seed: Some(1), train_frac: 1.0, max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, model: ModelSettings { entry_method: method, ..ModelSettings::default() @@ -423,6 +435,7 @@ fn a_search_holds_each_deals_own_value_and_reports_a_value_one_strategy_lacks() seed: Some(7), train_frac: 1.0, max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, model, }; let result = suggest(&deals, ¶ms, &SearchHandle::new()).expect("a result"); @@ -467,17 +480,267 @@ fn a_trade_floor_no_point_keeps_finds_nothing() { seed: Some(7), train_frac: 1.0, max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, model: ModelSettings { latency_ms: 0.0, ..ModelSettings::default() }, }; let result = suggest(&deals, ¶ms, &SearchHandle::new()); - assert!(result.is_none(), "{result:?}"); + assert_eq!(result.map(|r| r.values), Err(SearchMiss::Floor)); // Held by every point, the same search answers. let params = SearchParams { min_n: Some(8), ..params }; - assert!(suggest(&deals, ¶ms, &SearchHandle::new()).is_some()); + assert!(suggest(&deals, ¶ms, &SearchHandle::new()).is_ok()); +} + +/// A MoonShot corridor with its near bound, far bound and one-minute modifier. +fn corridor(far: f64, near: f64, add_1m: f64) -> MshotParams { + MshotParams { + price_pct: far, + price_min_pct: near, + modifiers: super::super::mshot::Modifiers { + add_1m, + ..Default::default() + }, + ..MshotParams::default() + } +} + +/// The corridor is what the modifiers make of the base fields: a variant may move distance +/// between `MShotPrice` and `MShotAdd*`, but not end nearer the price than the trade's own — and +/// a larger modifier brings the order NEARER on a coin whose delta fell. +#[test] +fn a_corridor_is_kept_on_its_bounds_not_on_its_fields() { + let own = corridor(2.5, 2.0, 0.02); + let rose = Deltas { + d1m: 1.0, + ..Deltas::default() + }; + let fell = Deltas { + d1m: -1.0, + ..Deltas::default() + }; + // The found variant of the screenshot: the base halved, the modifier eightfold. + assert!(!corridor(1.25, 0.6, 0.16).never_closer_than(&own, &[rose], true)); + // The same distance moved onto the modifier: kept while the coin rises… + assert!(corridor(2.5, 2.0, 0.05).never_closer_than(&own, &[rose], true)); + // …and not once it falls, anywhere in the order's life. + assert!(!corridor(2.5, 2.0, 0.05).never_closer_than(&own, &[rose, fell], true)); + // Wider on both bounds holds whatever the deltas; the trade's own holds against itself. + assert!(corridor(3.0, 2.5, 0.02).never_closer_than(&own, &[rose, fell], true)); + assert!(own.never_closer_than(&own, &[rose, fell], true)); + + // The guard reads each deal's own corridor, and a deal with the fact's entry keeps none. + let mut moonshot = prepared(1, 101.0); + moonshot.deal.own_entry = Some(EntryParams::MoonShot(own.clone())); + moonshot.deal.deltas = fell; + let guard = CorridorGuard::of(&[moonshot, prepared(2, 101.0)]); + assert_eq!(guard.deals.len(), 1); + let exit = ExitParams::default(); + let of = |variant: MshotParams| vec![(EntryParams::MoonShot(variant), exit.clone())]; + assert!(guard.holds(&[0, 0], &of(corridor(3.0, 2.5, 0.02)))); + assert!(!guard.holds(&[0, 0], &of(corridor(2.5, 2.0, 0.05)))); + assert!(guard.holds(&[0, 0], &[(EntryParams::Fact, exit.clone())])); + // A shift places the order at the far bound and reads nothing of the near one. + assert!(!corridor(2.5, 1.0, 0.02).never_closer_than(&own, &[rose], true)); + assert!(corridor(2.5, 1.0, 0.02).never_closer_than(&own, &[rose], false)); + let shifted = |mut variant: MshotParams| { + variant.model.entry_method = EntryMethod::Shift; + vec![(EntryParams::MoonShot(variant), exit.clone())] + }; + assert!(guard.holds(&[0, 0], &shifted(corridor(3.0, 1.0, 0.02)))); + assert!(!guard.holds(&[0, 0], &shifted(corridor(2.0, 2.0, 0.02)))); + + // The same rule asked of a typed variant before it is written: one of one MoonShot deal + // comes nearer, the other deal has no corridor to hold. + let mut moonshot = prepared(3, 101.0); + moonshot.deal.kind = "MoonShot".into(); + moonshot.deal.own_entry = Some(EntryParams::MoonShot(own.clone())); + let plain = prepared(4, 101.0); + let base = base(); + let check = |values: &[(String, String)]| { + let c = check_corridors( + [(&moonshot.deal, &base), (&plain.deal, &base)], + &HashMap::new(), + values, + ModelSettings::default(), + ); + (c.nearer, c.inverted, c.checked) + }; + let typed = |far: &str, near: &str| { + vec![ + ("MShotPrice".to_string(), far.to_string()), + ("MShotPriceMin".to_string(), near.to_string()), + ] + }; + assert_eq!(check(&typed("1.25", "0.6")), (1, 0, 1)); + assert_eq!(check(&typed("3", "2.5")), (0, 0, 1)); + // The screenshot's V1: the near field past the far one. Both effective bounds sit farther + // than the trade's (the far one lifted to the near), so the corridor rule passes it — the + // order rule does not. + assert_eq!(check(&typed("2.6", "2.7")), (0, 1, 1)); +} + +/// The search never proposes a corridor whose near field is at or past its far one, whatever +/// the switch; an Exit-only search leaves the strategy's own fields alone. +#[test] +fn an_inverted_corridor_is_refused_only_where_the_entry_is_searched() { + assert!(corridor(2.5, 2.0, 0.0).is_ordered()); + assert!(!corridor(1.7, 2.0, 0.0).is_ordered()); + assert!(!corridor(2.0, 2.0, 0.0).is_ordered()); + assert!(ordered(&EntryParams::Fact)); + assert!(!ordered(&EntryParams::MoonShot(corridor(1.7, 2.0, 0.0)))); + // A point inverts only a base that started in order: a strategy stored inverted does not + // refuse every point of the search. + let exit = ExitParams::default(); + let at = |far: f64, near: f64| { + ( + EntryParams::MoonShot(corridor(far, near, 0.0)), + exit.clone(), + ) + }; + assert!(inverts(&[true, true], &[at(2.5, 2.0), at(1.7, 2.0)])); + assert!(!inverts(&[true, false], &[at(2.5, 2.0), at(1.7, 2.0)])); + assert!(!inverts(&[true], &[at(2.5, 2.0)])); +} + +fn field(key: &str) -> &'static TickParam { + TICK_PARAMS + .iter() + .find(|f| f.key == key) + .expect("a grid field") +} + +/// A distance that only moves between two fields together: every single move scores worse, the +/// pair — `MShotPrice` a step down, `MShotAdd1minDelta` a step up — scores better, and the +/// descent finds it where one field at a time never could. +#[test] +fn a_pair_move_reaches_what_no_single_move_does() { + let price = field("MShotPrice"); + let add = field("MShotAdd1minDelta"); + let start: HashMap<&'static str, usize> = [(price.key, 10), (add.key, 5)].into(); + let target = (9, 6); + let evaluate = |point: &Point| -> Option { + let at = ( + grid_index(price, point, &start).expect("price"), + grid_index(add, point, &start).expect("add"), + ); + let mut tally = Tally::default(); + tally.push(if at == target { + 10.0 + } else if at == (10, 5) { + 5.0 + } else { + 1.0 + }); + Some(tally) + }; + let order = [price, add]; + let walked = descend( + Point::new(), + &order, + &order, + &start, + &evaluate, + 1, + DEFAULT_MAX_PASSES, + &SearchHandle::new(), + ) + .expect("not stopped"); + assert_eq!( + ( + grid_index(price, &walked.point, &start), + grid_index(add, &walked.point, &start) + ), + (Some(9), Some(6)) + ); + assert!((walked.score.expect("scored").profit - 10.0).abs() < 1e-9); + assert!(walked.converged); + // Without the pairs the walk stays where it began. + let alone = descend( + Point::new(), + &order, + &[], + &start, + &evaluate, + 1, + DEFAULT_MAX_PASSES, + &SearchHandle::new(), + ) + .expect("not stopped"); + assert!(alone.point.is_empty(), "{:?}", alone.point); +} + +/// A restart past the first starts near the base: one to three fields, a number field at most +/// three steps from where it stands. +#[test] +fn a_perturbed_start_stays_near_the_base() { + let price = field("MShotPrice"); + let add = field("MShotAdd1minDelta"); + let start: HashMap<&'static str, usize> = [(price.key, 10), (add.key, 5)].into(); + let order = [price, add]; + for restart in 1..200 { + let mut state = restart_seed(7, restart); + let mut point = Point::new(); + perturb(&mut point, &order, &start, &mut state); + assert!((1..=2).contains(&point.len()), "{point:?}"); + for f in order { + if let Some(at) = point.get(f.key).and_then(|_| grid_index(f, &point, &start)) { + assert!(at.abs_diff(start[f.key]) <= 3, "{} at {at}", f.key); + } + } + } + let mut items: Vec = (0..10).collect(); + shuffle(&mut items, &mut restart_seed(7, 1)); + let mut sorted = items.clone(); + sorted.sort(); + assert_eq!( + sorted, + (0..10).collect::>(), + "a shuffle keeps every field" + ); +} + +/// A search whose every point would bring a trade's corridor nearer the price than its own +/// comes back with that reason: the trade ran a corridor wider than any grid value, so nothing +/// the Entry group can be set to keeps it — and with the switch off the same search answers. +#[test] +fn a_search_that_no_point_can_keep_the_corridor_of_says_so() { + let mut deals: Vec = (1..=4).map(|uid| prepared(uid, 101.0)).collect(); + for d in &mut deals { + d.deal.kind = "MoonShot".into(); + d.deal.own_entry = Some(EntryParams::MoonShot(corridor(100.0, 90.0, 0.0))); + } + let held = HashMap::new(); + let defaults = HashMap::new(); + let locked = HashSet::new(); + let params = SearchParams { + held: &held, + defaults: &defaults, + kind: "MoonShot", + vary_entry: true, + vary_exit: false, + locked: &locked, + restarts: 2, + min_n: Some(1), + seed: Some(7), + train_frac: 1.0, + max_passes: 2, + keep_corridor: true, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + }; + let result = suggest(&deals, ¶ms, &SearchHandle::new()); + assert_eq!(result.map(|r| r.values), Err(SearchMiss::Corridor)); + let params = SearchParams { + keep_corridor: false, + ..params + }; + let result = suggest(&deals, ¶ms, &SearchHandle::new()); + assert!(!matches!(result, Err(SearchMiss::Corridor)), "{result:?}"); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs index ab92b172..4219e0d5 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs @@ -16,9 +16,9 @@ use gpui::prelude::FluentBuilder; use gpui::*; use moon_ui::{ - MoonButton, MoonButtonSize, MoonButtonVariant, MoonDropdown, MoonInput, MoonInputEvent, - MoonInputState, MoonPalette, MoonPopover, MoonPopoverPlacement, MoonTooltipView, h_flex, - v_flex, + MoonButton, MoonButtonSize, MoonButtonVariant, MoonCheckbox, MoonDropdown, MoonInput, + MoonInputEvent, MoonInputState, MoonPalette, MoonPopover, MoonPopoverPlacement, + MoonTooltipView, h_flex, v_flex, }; use rust_i18n::t; @@ -78,9 +78,12 @@ impl AnalyticsView { .to_string(), p.text_soft, ), - SuggState::Idle => match &self.ticks.sugg_note { - Some(note) => (note.clone(), p.amber), - None => (String::new(), p.text_muted), + // A note first; else how the last search went, so a restart or pass count that + // changed nothing can be seen to have changed nothing. + SuggState::Idle => match (&self.ticks.sugg_note, &self.ticks.last_result) { + (Some(note), _) => (note.clone(), p.amber), + (None, Some(result)) => (search_stats_line(&result.stats), p.text_muted), + (None, None) => (String::new(), p.text_muted), }, }; let placeholder = super::variants::DEFAULT_RESTARTS.to_string(); @@ -371,6 +374,24 @@ impl AnalyticsView { .text_color(moon(p.text_muted)) .child(t!(method_keys(method).1).to_string()), ) + .child( + MoonCheckbox::new("tun-cfg-corridor-x") + .label(t!("analytics.ticks.cfg_keep_corridor").to_string()) + .description(t!("analytics.ticks.cfg_keep_corridor_help").to_string()) + .checked(self.ticks.keep_corridor) + // `on_change` hands the callback an `&mut App`, not a `Context`. + .on_change({ + let view = cx.entity(); + move |checked: &bool, _w, app| { + let on = *checked; + view.update(app, |this, cx| { + this.ticks.keep_corridor = on; + this.persist_ticks_settings(cx); + cx.notify(); + }); + } + }), + ) .child(popup_section( t!("analytics.tuner.cfg_validation_section").to_string(), p, @@ -661,6 +682,26 @@ impl AnalyticsView { } } +/// The status band's account of the last search: restarts, the winning one, its passes and +/// whether it converged, how many distinct end points, how many points were scored. +fn search_stats_line(stats: &moon_core::db::tuner::ticks::SearchStats) -> String { + let passes = if stats.converged { + t!("analytics.ticks.stats_converged", n = stats.passes) + } else { + t!("analytics.ticks.stats_cut", n = stats.passes) + }; + t!( + "analytics.ticks.stats_line", + restarts = stats.restarts, + best = stats.best_restart, + passes = passes, + distinct = stats.distinct, + evals = stats.evaluations, + refused = stats.refused + ) + .to_string() +} + /// The popovers' scroll-bounded column. No padding, background, border or corners: the popover /// supplies them (see the filter's settings popover). fn popup_frame(id: &'static str, window: &Window, cx: &Context) -> Stateful
{ diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 147a8fc5..de8d6103 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -263,6 +263,7 @@ fn invalidate_stops_the_search_and_drops_the_variant_scores_but_keeps_the_edits( train: Default::default(), holdout: Some(Default::default()), seed: 1, + stats: Default::default(), }); state.invalidate(); assert!(handle.is_cancelled()); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 80a1d1a2..c7986aed 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -336,6 +336,9 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) passes: String, /// The group gate, per cent of reproduced trades; empty = [`DEFAULT_GATE_PCT`]. pub(in crate::analytics::tuner) gate_pct: String, + /// Whether the search keeps every trade's entry corridor at least as far from the price as + /// the trade's own (`SearchParams::keep_corridor`). On unless the user turned it off. + pub(in crate::analytics::tuner) keep_corridor: bool, /// Whether the search settings popover is open. pub(in crate::analytics::tuner) sugg_cfg_open: bool, /// Whether the model settings popover is open. @@ -415,6 +418,7 @@ impl Default for TicksState { last_seed: None, passes: String::new(), gate_pct: String::new(), + keep_corridor: true, sugg_cfg_open: false, model_cfg_open: false, sugg: SuggState::Idle, @@ -464,6 +468,7 @@ impl TicksState { self.gate_pct = saved.gate_pct.map(|n| n.to_string()).unwrap_or_default(); self.locked = saved.locked.iter().cloned().collect(); self.trade.open = saved.trade_open; + self.keep_corridor = !saved.allow_closer_corridor; } /// The axis' settings as the layout persists them, the model's from their process-wide @@ -481,6 +486,7 @@ impl TicksState { locked, model: super::model_cfg::current(), trade_open: self.trade.open, + allow_closer_corridor: !self.keep_corridor, } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 384c120a..25fcff96 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -15,6 +15,7 @@ //! core's did (`record::StopAnchor`) — the book the tape does not carry, answered by the fact. use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use std::time::Duration; use gpui::*; @@ -22,14 +23,15 @@ use rust_i18n::t; use super::super::super::AnalyticsView; use super::super::shared::N_VAR; -use super::state::{NowValue, SuggState}; +use super::state::SuggState; use super::tape::{PendingDeal, prepare_sample}; use crate::analytics::bg::ReadLane; use moon_core::db::tuner::threshold_search::SearchHandle; use moon_core::db::tuner::ticks::TICK_PARAMS; use moon_core::db::tuner::ticks::params::ParamGroup; use moon_core::db::tuner::ticks::search::{ - DEFAULT_MAX_PASSES, SearchParams, suggest, train_len, variant_tally_by_deal, + DEFAULT_MAX_PASSES, SearchMiss, SearchParams, check_corridors, suggest, train_len, + variant_tally_by_deal, }; use moon_core::db::tuner::ticks::stats_of; @@ -314,6 +316,7 @@ impl AnalyticsView { .ok() .filter(|n| *n > 0); let train_frac = super::super::filter::state::train_frac(self.ticks.train_pct); + let keep_corridor = self.ticks.keep_corridor; // A floor over the slice the search fits on no point can keep: say so before a run that // can only come back empty. let closes: Vec = pending.iter().map(|d| d.deal.close_ms).collect(); @@ -351,6 +354,7 @@ impl AnalyticsView { train_frac, max_passes, model, + keep_corridor, }; suggest(&deals, ¶ms, &handle) }, @@ -360,7 +364,7 @@ impl AnalyticsView { } this.ticks.sugg = SuggState::Idle; match result { - Some(result) => { + Ok(result) => { match only { None => { this.ticks.variants[0] = @@ -381,11 +385,17 @@ impl AnalyticsView { this.ticks.last_result = Some(result); this.arm_ticks_variants(cx); } - // Under a floor, "nothing" is that no point kept it. - None => { - this.ticks.sugg_note = Some(match min_n { - Some(n) => t!("analytics.ticks.sugg_floor", n = n).to_string(), - None => t!("analytics.ticks.sugg_none").to_string(), + // Why nothing: the floor no point kept — the typed one, or the search's own + // tenth of the training slice —, the corridor none kept, or nothing at all. + Err(miss) => { + this.ticks.sugg_note = Some(match miss { + SearchMiss::Floor => t!( + "analytics.ticks.sugg_floor", + n = min_n.unwrap_or((train_n as i64 / 10).max(1)) + ) + .to_string(), + SearchMiss::Corridor => t!("analytics.ticks.sugg_corridor").to_string(), + SearchMiss::Nothing => t!("analytics.ticks.sugg_none").to_string(), }); } } @@ -412,7 +422,7 @@ impl AnalyticsView { log::info!("analytics: 'Save' (ticks) - no variant to write"); return; } - let warns = self.ticks_change_warnings(&changes); + let warns = self.ticks_change_warnings(&changes, cx); self.open_change_dialog(targets, changes, None, Vec::new(), warns, false, cx); } @@ -426,39 +436,74 @@ impl AnalyticsView { return; }; let changes = self.ticks.variant_changes(0); - let warns = self.ticks_change_warnings(&changes); + let warns = self.ticks_change_warnings(&changes, cx); self.open_copy_with(target, changes, warns, window, cx); } - /// The honesty line of a write: a closer `MShotPrice` is UNDERESTIMATED by the sample - /// (spikes the real order never reached are not in the report), so the dialog says so — - /// when the value is closer than ANY strategy's own, not only when they all agree on one. - fn ticks_change_warnings(&self, changes: &[(String, String)]) -> Vec { - let mut warns = Vec::new(); - let parse = |v: &str| v.replace(',', ".").parse::().ok(); - let Some(value) = changes - .iter() - .find(|(k, _)| k == "MShotPrice") - .and_then(|(_, v)| parse(v)) - else { - return warns; + /// The honesty lines of a write. A variant whose corridor fields are inverted + /// (`MShotPriceMin ≥ MShotPrice`, which the search never proposes) is judged on a corridor + /// the core's fields do not describe; one whose corridor comes nearer the price than a + /// trade's own — the rule the search keeps under "keep the corridor" + /// (`search::check_corridors`), asked of В1 as it will be written, typed or found — is + /// judged on a sample without the spikes such an order would catch, so the dialog says on + /// how many trades it does. Over the rows the columns and the search replay, each on its + /// strategy's current values as they take them, so "M" is the column's "by N"; and only + /// for a variant that moves an Entry field — one that leaves the corridor alone moves + /// nothing a warning could be about. + fn ticks_change_warnings( + &self, + changes: &[(String, String)], + cx: &Context, + ) -> Vec { + let moves_entry = changes.iter().any(|(key, _)| { + TICK_PARAMS + .iter() + .any(|f| f.key == key && f.group == ParamGroup::Entry) + }); + let Some(data) = self.ticks.data.data().filter(|_| moves_entry) else { + return Vec::new(); }; - let closer = self.ticks.data.data().is_some_and(|d| { - let agreed = match d.now.get("MShotPrice") { - Some(NowValue::Same(v)) => parse(v), - _ => None, - }; - agreed - .into_iter() - .chain( - d.own - .values() - .filter_map(|own| own.get("MShotPrice").and_then(|v| parse(v))), + let deals: Vec<( + &moon_core::db::tuner::ticks::Deal, + Arc>, + )> = data + .replayable() + .map(|r| { + let own = data + .own + .get(&(r.deal.strategy_id, r.deal.core_uid)) + .cloned() + .unwrap_or_default(); + (&r.deal, own) + }) + .collect(); + let defaults = self.filter_defaults(cx); + let check = check_corridors( + deals.iter().map(|(deal, own)| (*deal, own.as_ref())), + &defaults, + changes, + super::model_cfg::current(), + ); + let mut warns = Vec::new(); + if check.inverted > 0 { + warns.push( + t!( + "analytics.ticks.inverted_warn", + n = check.inverted, + m = check.checked ) - .any(|base| value < base) - }); - if closer { - warns.push(t!("analytics.ticks.closer_warn").to_string()); + .to_string(), + ); + } + if check.nearer > 0 { + warns.push( + t!( + "analytics.ticks.closer_warn", + n = check.nearer, + m = check.checked + ) + .to_string(), + ); } warns } diff --git a/locales/analytics.yml b/locales/analytics.yml index 359b8a37..7b9445ac 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1960,6 +1960,34 @@ analytics.ticks.sugg_none: ru: "Подбор ничего не нашёл" en: "The search found nothing" es: "La búsqueda no encontró nada" +analytics.ticks.stats_line: + ru: "перезапусков %{restarts} · лучший — №%{best} · %{passes} · разных итогов %{distinct} · без разрешённой точки %{refused} · оценено точек %{evals}" + en: "restarts %{restarts} · best from #%{best} · %{passes} · distinct ends %{distinct} · with no allowed point %{refused} · points scored %{evals}" + es: "reinicios %{restarts} · mejor del n.º %{best} · %{passes} · finales distintos %{distinct} · sin punto permitido %{refused} · puntos evaluados %{evals}" +analytics.ticks.stats_converged: + ru: "сошёлся за %{n} прох." + en: "converged in %{n} passes" + es: "convergió en %{n} pasadas" +analytics.ticks.stats_cut: + ru: "не сошёлся за %{n} прох. — упёрся в предел" + en: "still improving after %{n} passes — cut by the limit" + es: "seguía mejorando tras %{n} pasadas — cortado por el límite" +analytics.ticks.sugg_corridor: + ru: "Ни один вариант не держит коридор: MShotPriceMin меньше MShotPrice и, с галкой, не ближе к цене, чем у сделки" + en: "No variant keeps the corridor: MShotPriceMin below MShotPrice and, with the switch on, no nearer the price than the trade's" + es: "Ninguna variante mantiene el corredor: MShotPriceMin por debajo de MShotPrice y, con la casilla, no más cerca del precio que el de la operación" +analytics.ticks.inverted_warn: + ru: "MShotPriceMin не меньше MShotPrice у %{n} из %{m} сделок: такой коридор полями не описан, что сделает ядро — неизвестно, оценка ненадёжна" + en: "MShotPriceMin is not below MShotPrice on %{n} of %{m} trades: the fields describe no such corridor, what the core does with it is unknown, the estimate is unreliable" + es: "MShotPriceMin no está por debajo de MShotPrice en %{n} de %{m} operaciones: los campos no describen ese corredor, se desconoce qué hará el núcleo, la estimación no es fiable" +analytics.ticks.cfg_keep_corridor: + ru: "Не приближать коридор к цене" + en: "Keep the corridor no nearer the price" + es: "No acercar el corredor al precio" +analytics.ticks.cfg_keep_corridor_help: + ru: "Итоговый коридор варианта (MShotPrice, MShotPriceMin и прибавки MShotAdd по дельтам) ни в один момент жизни ордера не ближе к цене, чем был у сделки. Расстояние можно перераспределять между полями, но не сокращать: ордер ближе к цене ловит прострелы, которых нет в выборке." + en: "The variant's resulting corridor (MShotPrice, MShotPriceMin and the MShotAdd delta additions) is at no moment of the order's life nearer the price than the trade's was. The distance may be moved between the fields, not shortened: an order nearer the price catches spikes that are not in the sample." + es: "El corredor resultante de la variante (MShotPrice, MShotPriceMin y las adiciones MShotAdd por deltas) en ningún momento de la vida de la orden está más cerca del precio que el de la operación. La distancia puede repartirse entre los campos, no acortarse: una orden más cerca del precio atrapa picos que no están en la muestra." analytics.ticks.sugg_floor: ru: "Ни один вариант не держит ≥ %{n} сделок" en: "No variant keeps ≥ %{n} trades" @@ -1969,9 +1997,9 @@ analytics.ticks.sugg_floor_sample: en: "A floor of %{n} trades exceeds the sample: %{m} trades with tape in memory to train on" es: "Un mínimo de %{n} operaciones supera la muestra: %{m} con cinta en memoria para entrenar" analytics.ticks.closer_warn: - ru: "MShotPrice ближе фактического: оценка занижена — прострелы, до которых реальный ордер не дотянулся, в отчёте отсутствуют" - en: "MShotPrice closer than the fact: underestimated — spikes the real order never reached are not in the report" - es: "MShotPrice más cerca que el hecho: subestimado — los picos que la orden real nunca alcanzó no están en el informe" + ru: "Коридор ближе к цене, чем был у %{n} из %{m} сделок: оценка ненадёжна — такой ордер поймал бы прострелы, которых в выборке нет" + en: "The corridor is nearer the price than it was on %{n} of %{m} trades: the estimate is unreliable — such an order would catch spikes that are not in the sample" + es: "El corredor está más cerca del precio que en %{n} de %{m} operaciones: la estimación no es fiable — esa orden atraparía picos que no están en la muestra" analytics.ticks.var_untouched: ru: "без изменений" en: "unchanged" From 58558006066e047810db1e762b71bdf0bc9bc494 Mon Sep 17 00:00:00 2001 From: guyverino Date: Thu, 24 Sep 2026 11:01:56 +0200 Subject: [PATCH 33/51] feat(tuner): lay the Entry/Exit grid out by the Strategies window's sections 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 --- crates/moon-core/src/db/tuner/ticks/params.rs | 139 ++++-- crates/moon-core/src/db/tuner/ticks/tests.rs | 46 ++ .../src/analytics/tuner/ticks/grid.rs | 418 ++++++++++++++---- .../src/analytics/tuner/ticks/load.rs | 60 ++- .../src/analytics/tuner/ticks/mod.rs | 1 + .../src/analytics/tuner/ticks/sections.rs | 212 +++++++++ .../analytics/tuner/ticks/sections/tests.rs | 146 ++++++ .../src/analytics/tuner/ticks/state.rs | 17 +- crates/moon-ui-gpui/src/design.rs | 22 + crates/moon-ui-gpui/src/design/tests.rs | 86 ++++ crates/moon-ui-gpui/src/strategies/mod.rs | 4 +- .../moon-ui-gpui/src/strategies/sections.rs | 4 +- .../moon-ui-gpui/src/strategies/settings.rs | 6 + locales/analytics.yml | 16 + 14 files changed, 1062 insertions(+), 115 deletions(-) create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index be0b96d8..f7c31afc 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -23,6 +23,46 @@ pub enum ParamGroup { Exit, } +/// The strategy editor's section a field sits in — where Moonbot's Strategies window shows it. +/// +/// The grid lays its rows out by section, not by [`ParamGroup`]: a MoonShot's +/// `MShotSellAtLastPrice` moves the exit and sits in "Strategy settings" beside the entry +/// corridor, and it is found where the Strategies window has it. The group still decides what the +/// search gates on; the section only decides where the row is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ParamSection { + StrategySettings, + Stops, + SellOrder, + SellShot, + SellSpread, + DeltaModifiers, +} + +impl ParamSection { + /// The sections of the grid, in the order it draws them. + pub const GRID_ORDER: [ParamSection; 6] = [ + ParamSection::StrategySettings, + ParamSection::Stops, + ParamSection::SellOrder, + ParamSection::SellShot, + ParamSection::SellSpread, + ParamSection::DeltaModifiers, + ]; + + /// The section's title as the strategy schema spells it (`assets/param_deps.toml`). + pub fn schema_title(self) -> &'static str { + match self { + ParamSection::StrategySettings => "Strategy settings", + ParamSection::Stops => "Stops", + ParamSection::SellOrder => "Sell order", + ParamSection::SellShot => "Sell order / SellShot", + ParamSection::SellSpread => "Sell order / SellSpread", + ParamSection::DeltaModifiers => "Delta Modifiers", + } + } +} + /// How a parameter is typed and, for the search, which values it may take. #[derive(Clone, Copy, Debug, PartialEq)] pub enum ParamKind { @@ -41,6 +81,9 @@ pub struct TickParam { /// The strategy field name, as stored and as shown in the grid. pub key: &'static str, pub group: ParamGroup, + /// Where the grid draws the row when the live schema does not place it — no core with a + /// schema is connected. The schema, when there is one, wins. + pub section: ParamSection, pub kind: ParamKind, /// Strategy kinds whose grid shows this parameter; empty means every kind. pub kinds: &'static [&'static str], @@ -132,6 +175,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotPrice", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_PRICE }, kinds: MSHOT, not_kinds: &[], @@ -139,6 +183,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotPriceMin", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_PRICE_MIN, }, @@ -148,6 +193,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotUsePrice", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Enum(&["Trade", "ASK", "BID"]), kinds: MSHOT, not_kinds: &[], @@ -155,6 +201,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotRaiseWait", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_WAIT_S }, kinds: MSHOT, not_kinds: &[], @@ -162,6 +209,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotReplaceDelay", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_WAIT_S }, kinds: MSHOT, not_kinds: &[], @@ -169,6 +217,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotMinusSatoshi", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Bool, kinds: MSHOT, not_kinds: &[], @@ -176,6 +225,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "FastShotAlgo", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Bool, kinds: MSHOT, not_kinds: &[], @@ -183,6 +233,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAddHourlyDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -190,6 +241,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAdd3hDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -197,6 +249,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAdd15minDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -204,6 +257,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAdd5minDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -211,6 +265,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAdd1minDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -218,6 +273,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAdd24hDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -225,6 +281,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAddMarkDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -232,6 +289,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAddMarketDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -239,6 +297,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAddBTCDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -246,6 +305,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAddBTC5mDelta", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -253,6 +313,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAddPriceBug", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADD }, kinds: MSHOT, not_kinds: &[], @@ -260,6 +321,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotAddDistance", group: ParamGroup::Entry, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_DISTANCE, }, @@ -269,6 +331,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "SellPrice", group: ParamGroup::Exit, + section: ParamSection::SellOrder, kind: ParamKind::Num { grid: GRID_SELL_PRICE, }, @@ -280,6 +343,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotSellAtLastPrice", group: ParamGroup::Exit, + section: ParamSection::StrategySettings, kind: ParamKind::Bool, kinds: MSHOT, not_kinds: &[], @@ -287,6 +351,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotSellPriceAdjust", group: ParamGroup::Exit, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_ADJUST }, kinds: MSHOT, not_kinds: &[], @@ -294,6 +359,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "HookSellLevel", group: ParamGroup::Exit, + section: ParamSection::StrategySettings, kind: ParamKind::Num { grid: GRID_HOOK_LEVEL, }, @@ -303,43 +369,57 @@ pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "SellDelay", group: ParamGroup::Exit, + section: ParamSection::SellOrder, kind: ParamKind::Num { grid: GRID_SELL_DELAY_MS, }, kinds: ANY, not_kinds: &[], }, - exit_num("PriceDownTimer", GRID_PD_TIMER_S), - exit_num("PriceDownPercent", GRID_PD_PCT), - exit_num("PriceDownDelay", GRID_PD_DELAY_S), - exit_bool("PriceDownRelative"), - exit_num("PriceDownAllowedDrop", GRID_DROP), - exit_num("SellLevelDelay", GRID_SL_DELAY_S), - exit_num("SellLevelDelayNext", GRID_SL_DELAY_S), - exit_num("SellLevelTime", GRID_SL_TIME_S), - exit_num("SellLevelCount", GRID_SL_COUNT), - exit_num("SellLevelAdjust", GRID_DROP), - exit_bool("SellLevelRelative"), - exit_num("SellLevelAllowedDrop", GRID_DROP), - exit_num("SellLevelWorkTime", GRID_SL_TIME_S), - exit_bool("IgnoreSellShot"), - exit_num("SellShotDistance", GRID_SS_DISTANCE), - exit_num("SellShotCorridor", GRID_SS_CORRIDOR), - exit_num("SellShotCalcInterval", GRID_SS_INTERVAL_S), - exit_num("SellShotRaiseWait", GRID_SS_WAIT_S), - exit_num("SellShotReplaceDelay", GRID_SS_WAIT_S), - exit_num("SellShotAllowedUp", GRID_SS_BOUND), - exit_num("SellShotAllowedDown", GRID_SS_BOUND), - exit_num("SellShotDelay", GRID_SS_WAIT_S), - exit_num("StopLoss", GRID_STOP), - exit_num("StopLossDelay", GRID_STOP_DELAY_S), + exit_num("PriceDownTimer", ParamSection::SellOrder, GRID_PD_TIMER_S), + exit_num("PriceDownPercent", ParamSection::SellOrder, GRID_PD_PCT), + exit_num("PriceDownDelay", ParamSection::SellOrder, GRID_PD_DELAY_S), + exit_bool("PriceDownRelative", ParamSection::SellOrder), + exit_num("PriceDownAllowedDrop", ParamSection::SellOrder, GRID_DROP), + exit_num("SellLevelDelay", ParamSection::SellOrder, GRID_SL_DELAY_S), + exit_num( + "SellLevelDelayNext", + ParamSection::SellOrder, + GRID_SL_DELAY_S, + ), + exit_num("SellLevelTime", ParamSection::SellOrder, GRID_SL_TIME_S), + exit_num("SellLevelCount", ParamSection::SellOrder, GRID_SL_COUNT), + exit_num("SellLevelAdjust", ParamSection::SellOrder, GRID_DROP), + exit_bool("SellLevelRelative", ParamSection::SellOrder), + exit_num("SellLevelAllowedDrop", ParamSection::SellOrder, GRID_DROP), + exit_num("SellLevelWorkTime", ParamSection::SellOrder, GRID_SL_TIME_S), + exit_bool("IgnoreSellShot", ParamSection::SellShot), + exit_num("SellShotDistance", ParamSection::SellShot, GRID_SS_DISTANCE), + exit_num("SellShotCorridor", ParamSection::SellShot, GRID_SS_CORRIDOR), + exit_num( + "SellShotCalcInterval", + ParamSection::SellShot, + GRID_SS_INTERVAL_S, + ), + exit_num("SellShotRaiseWait", ParamSection::SellShot, GRID_SS_WAIT_S), + exit_num( + "SellShotReplaceDelay", + ParamSection::SellShot, + GRID_SS_WAIT_S, + ), + exit_num("SellShotAllowedUp", ParamSection::SellShot, GRID_SS_BOUND), + exit_num("SellShotAllowedDown", ParamSection::SellShot, GRID_SS_BOUND), + exit_num("SellShotDelay", ParamSection::SellShot, GRID_SS_WAIT_S), + exit_num("StopLoss", ParamSection::Stops, GRID_STOP), + exit_num("StopLossDelay", ParamSection::Stops, GRID_STOP_DELAY_S), ]; /// A numeric field of the Exit group every kind understands. -const fn exit_num(key: &'static str, grid: &'static [f64]) -> TickParam { +const fn exit_num(key: &'static str, section: ParamSection, grid: &'static [f64]) -> TickParam { TickParam { key, group: ParamGroup::Exit, + section, kind: ParamKind::Num { grid }, kinds: ANY, not_kinds: &[], @@ -347,10 +427,11 @@ const fn exit_num(key: &'static str, grid: &'static [f64]) -> TickParam { } /// A boolean field of the Exit group every kind understands. -const fn exit_bool(key: &'static str) -> TickParam { +const fn exit_bool(key: &'static str, section: ParamSection) -> TickParam { TickParam { key, group: ParamGroup::Exit, + section, kind: ParamKind::Bool, kinds: ANY, not_kinds: &[], @@ -422,6 +503,12 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "MShotAdd5sDelta", ]; +/// Whether the models read `key` from the strategy without the grid offering it as a knob — +/// the grid draws such a field as fixed rather than as outside the model. +pub fn is_model_only(key: &str) -> bool { + MODEL_ONLY_KEYS.contains(&key) +} + /// Every field name the models read — [`TICK_PARAMS`] plus [`MODEL_ONLY_KEYS`] — for a /// `strategy_current_values` read. pub fn param_keys() -> Vec { diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 64602d86..b9764cc1 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -1946,3 +1946,49 @@ fn sell_modifiers_lift_the_take_by_the_faq_example() { let take = ExitModel::new(&off).take_level(&d, &[], fill); assert!((take - 101.0).abs() < 1e-9, "{take}"); } + +/// Every knob's fallback section is the one `assets/param_deps.toml` files it under — the +/// repository's copy of the schema's sections. A knob tagged elsewhere would be drawn in the +/// wrong section of the grid whenever no core with a schema is connected. +#[test] +fn knob_sections_match_the_schema_copy() { + use super::params::ParamSection; + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/param_deps.toml"); + let text = std::fs::read_to_string(&path).expect("param_deps.toml"); + let mut section = String::new(); + let mut filed: HashMap = HashMap::new(); + for line in text.lines() { + if let Some(title) = line + .strip_prefix("# === ") + .and_then(|l| l.strip_suffix(" ===")) + { + section = title.to_string(); + } else if let Some((key, _)) = line.strip_prefix('"').and_then(|r| r.split_once('"')) { + filed.insert(key.to_string(), section.clone()); + } + } + // Knobs the copy does not list at all: read off MoonBot.exe after the copy was made (the + // spec's §11, 20.09), so its section there is unconfirmed. A new absence fails below. + const NOT_IN_COPY: &[&str] = &["MShotAdd24hDelta"]; + for p in TICK_PARAMS { + if NOT_IN_COPY.contains(&p.key) { + assert!(!filed.contains_key(p.key), "{} is in the copy now", p.key); + continue; + } + assert_eq!( + filed.get(p.key).map(String::as_str), + Some(p.section.schema_title()), + "{} is tagged {:?}", + p.key, + p.section + ); + } + // Every section the grid draws is one the schema copy has. + for s in ParamSection::GRID_ORDER { + assert!( + filed.values().any(|t| t == s.schema_title()), + "{s:?} has no section in param_deps.toml" + ); + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index 526faf41..62fe854f 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -3,11 +3,19 @@ //! a click selects it for "Search" on one field — the value the selected strategies hold, which //! a click sends to В1, and the two variant columns with the copy arrows and the clear crosses. //! -//! The rows come in two groups, Entry and Exit. A group the model does not reproduce well enough -//! (the share gate of the search settings) is not searched, and its heading says so; the Entry -//! group folds to one line where a kind in the scope has no entry model. A field the entry -//! method does not read — the path-only fields under the shift — is greyed out: varying it would -//! move no column. +//! The rows come by the strategy editor's sections (`sections.rs`) — Strategy settings, Stops, +//! Sell order, SellShot, SellSpread, Delta Modifiers — each with every field it holds for the +//! scope's kinds, and a tick in its heading that admits all its knobs at once. Only the knobs +//! are live; a field the model reads but does not turn, or does not know at all, is drawn +//! greyed with the strategies' value, so the grid shows the whole section as Moonbot does. +//! +//! The search still gates by group, Entry and Exit: a group the model does not reproduce well +//! enough (the share gate of the search settings) is not searched, and the first section holding +//! its knobs says so; where a kind in the scope has no entry model, the Entry knobs are drawn +//! fixed. A field the entry method does not read — the path-only fields under the shift — is +//! greyed out: varying it would move no column. + +use std::sync::Arc; use gpui::prelude::FluentBuilder; use gpui::*; @@ -17,18 +25,22 @@ use moon_ui::{ use rust_i18n::t; use super::super::super::AnalyticsView; -use super::super::shared::{N_VAR, TunerKind, glyph_btn}; +use super::super::shared::{N_VAR, TunerKind, collapse_caret, glyph_btn}; +use super::sections::{GridSection, RowRole, layout}; use super::state::{NowValue, TicksData}; use crate::design; use crate::design::{moon, moon_alpha}; -use moon_core::db::tuner::ticks::params::{ParamGroup, TickParam, params_for}; +use moon_core::db::tuner::ticks::params::{ParamGroup, ParamSection, TickParam}; /// Width of the strategy and variant cells, font-scaled px. const CELL_W: f32 = 60.0; +/// Left inset of a field row, ui px: its tick sits under its section's tick, past the caret, +/// so the row reads as inside the section. +const ROW_INDENT: f32 = 30.0; impl AnalyticsView { - /// The grid panel: the shared toolbar (title, Copy, Save), the search row, then the two - /// groups, scrolling. + /// The grid panel: the shared toolbar (title, Copy, Save), the search row, then the + /// sections, scrolling. pub(in crate::analytics::tuner) fn ticks_grid( &mut self, p: MoonPalette, @@ -42,19 +54,70 @@ impl AnalyticsView { ); let cfg_row = self.shell_config_row(TunerKind::Ticks, p, window, cx); let data = self.ticks.data.data().cloned(); - let fields = scope_fields(data.as_deref()); + let schema_sig = super::sections::schema_signature(self.backend.read(cx).session.store()); + // A core's schema that arrived, changed or went after the latest load chose its keys + // leaves the layout short of that core's fields and their values: ask the refresh gate + // for the visible axis again, once per signature. Deferred — a load is not started from + // inside a render. + if self.ticks.keys_sig.is_some_and(|sig| sig != schema_sig) + && self.ticks.schema_reload != Some(schema_sig) + { + self.ticks.schema_reload = Some(schema_sig); + cx.defer_in(window, |this, _window, cx| { + this.request_report_refresh( + crate::analytics::refresh::RefreshUrgency::User, + false, + cx, + ) + }); + } + let entry_on = data.as_ref().is_some_and(|d| d.entry_modelled()); + // Before the first load there is no layout; the bare headings stand in for it. + let sections: Arc<[GridSection]> = match data.as_ref() { + Some(d) if !d.grid.is_empty() => d.grid.clone(), + _ => layout(&[], &[]).into(), + }; + let live: Vec<&'static str> = sections + .iter() + .flat_map(GridSection::knobs) + .filter(|k| knob_ticks(k, entry_on)) + .map(|k| k.key) + .collect(); let mut grid = v_flex() .w_full() .flex_none() - .child(self.ticks_grid_header(&fields, p, cx)); - for group in [ParamGroup::Entry, ParamGroup::Exit] { - grid = grid.child(self.ticks_group_header(group, data.as_deref(), p, cx)); - if group == ParamGroup::Entry && !data.as_ref().is_some_and(|d| d.entry_modelled()) { - continue; - } - for field in fields.iter().filter(|f| f.group == group) { - let now = data.as_ref().and_then(|d| d.now.get(field.key).cloned()); - grid = grid.child(self.ticks_field_row(field.key, now, p, window, cx)); + .child(self.ticks_grid_header(live, p, cx)); + // A section the scope's kinds leave empty is dropped once the scope is known; before + // that every heading stands, the first carrying the scope's note. + let scoped = data.as_ref().is_some_and(|d| !d.kinds.is_empty()); + let mut noted: Vec = Vec::new(); + let mut first = true; + for section in sections.iter().filter(|s| !scoped || !s.rows.is_empty()) { + grid = grid.child(self.ticks_section_header( + section, + data.as_deref(), + first, + &mut noted, + p, + cx, + )); + first = false; + // A folded section keeps one row in sight: the field selected for "Search", so what + // the button would search is never hidden. + let open = self.ticks.open_sections.contains(§ion.section); + let sel = self.ticks.sel_field; + for row in section + .rows + .iter() + .filter(|r| open || sel == Some(r.key.as_str())) + { + let now = data.as_ref().and_then(|d| d.now.get(&row.key).cloned()); + grid = grid.child(match row.role { + RowRole::Knob(knob) if knob_live(knob, entry_on) => { + self.ticks_field_row(knob.key, now, p, window, cx) + } + role => self.ticks_fixed_row(&row.key, role, now, data.as_deref(), p, cx), + }); } } v_flex() @@ -94,15 +157,36 @@ impl AnalyticsView { cx.notify(); } - /// The column headings: the master tick, field · strategy · В1 → ✕ · В2 ← ✕. + /// Open or fold one section of the grid. + fn ticks_toggle_section(&mut self, section: ParamSection, cx: &mut Context) { + if !self.ticks.open_sections.remove(§ion) { + self.ticks.open_sections.insert(section); + } + cx.notify(); + } + + /// The state of a tick that admits all of `keys`: `(checked, indeterminate)` — every one + /// admitted to the search, or some but not all. No keys, no tick. + fn ticks_tick_state(&self, keys: &[&'static str]) -> (bool, bool) { + let on = keys + .iter() + .filter(|k| !self.ticks.locked.contains(**k)) + .count(); + ( + !keys.is_empty() && on == keys.len(), + on > 0 && on < keys.len(), + ) + } + + /// The column headings: the master tick over every live knob, field · strategy · В1 → ✕ · + /// В2 ← ✕. fn ticks_grid_header( &self, - fields: &[&'static TickParam], + keys: Vec<&'static str>, p: MoonPalette, cx: &Context, ) -> AnyElement { - let keys: Vec<&'static str> = fields.iter().map(|f| f.key).collect(); - let all_on = !keys.is_empty() && keys.iter().all(|k| !self.ticks.locked.contains(*k)); + let (all_on, some_on) = self.ticks_tick_state(&keys); let cell = |text: String| { div() .w(design::font_w_px(cx, CELL_W)) @@ -124,6 +208,8 @@ impl AnalyticsView { div().flex_none().child( MoonCheckbox::new("an-ticks-en-all") .checked(all_on) + .indeterminate(some_on) + .disabled(keys.is_empty()) .size(design::CONTROL_TIER) .on_change({ let view = cx.entity(); @@ -137,18 +223,21 @@ impl AnalyticsView { ) // The caption toggles the lot too, as the filter grid's does — a click target rather // than the checkbox's own label, which would widen the checkbox and push every - // heading after it out of line. + // heading after it out of line. It answers as the box does: a half-set box reads as + // ticked, so a click on either unticks all. .child( div() .id("an-ticks-en-all-lbl") .flex_1() .min_w_0() .truncate() - .cursor_pointer() .child(t!("analytics.tuner.field").to_string()) - .on_click(cx.listener(move |this, _, _, cx| { - this.ticks_set_all(&keys, !all_on, cx); - })), + .when(!keys.is_empty(), |el| { + el.cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + this.ticks_set_all(&keys, !(all_on || some_on), cx); + })) + }), ) .child(cell(t!("analytics.tuner.strat_chip").to_string())); for vi in 0..N_VAR { @@ -192,54 +281,109 @@ impl AnalyticsView { head.into_any_element() } - /// A group's heading: its name, and why it is not searched when it is not — the kinds whose - /// entry is taken from the fact, or a share of reproduced trades under the gate. - fn ticks_group_header( + /// Why a search group is not searched, when it is not — the kinds whose entry is taken from + /// the fact, or a share of reproduced trades under the gate — with the colour to say it in. + fn ticks_group_note( &self, group: ParamGroup, - data: Option<&TicksData>, + d: &TicksData, p: MoonPalette, - cx: &Context, - ) -> AnyElement { - let title = match group { - ParamGroup::Entry => t!("analytics.ticks.group_entry"), - ParamGroup::Exit => t!("analytics.ticks.group_exit"), - } - .to_string(); - let gate = self.ticks.gate(); - let note: Option<(String, u32)> = match data { - // Only a LOADED empty scope says so; a load in flight or a failed one has its own - // note in the table. - Some(d) if d.kinds.is_empty() => { - Some((t!("analytics.ticks.no_deals").to_string(), p.text_muted)) - } - Some(d) if group == ParamGroup::Entry && !d.entry_modelled() => Some(( + ) -> Option<(String, u32)> { + if group == ParamGroup::Entry && !d.entry_modelled() { + return Some(( t!( "analytics.ticks.entry_from_fact", kinds = d.unmodelled_kinds().join(", ") ) .to_string(), p.text_muted, + )); + } + let gate = self.ticks.gate(); + let (hits, n) = d.share_of(group); + let name = match group { + ParamGroup::Entry => t!("analytics.ticks.group_entry"), + ParamGroup::Exit => t!("analytics.ticks.group_exit"), + }; + match d.group_passes(group, gate) { + Some(false) => Some(( + format!( + "{name}: {}", + t!( + "analytics.ticks.vary_gated", + hits = hits, + n = n, + gate = (gate * 100.0).round() as i64 + ) + ), + p.orange, + )), + None => Some(( + format!("{name}: {}", t!("analytics.ticks.vary_unknown")), + p.text_muted, )), + Some(true) => None, + } + } + + /// A section's heading: the tick admitting all its live knobs, its name as the Strategies + /// window titles it (the human gloss under that window's own preference), and the notes of + /// the search groups first met in it (`noted` carries the groups an earlier heading already + /// spoke for). + fn ticks_section_header( + &self, + section: &GridSection, + data: Option<&TicksData>, + first: bool, + noted: &mut Vec, + p: MoonPalette, + cx: &Context, + ) -> AnyElement { + let title = crate::strategies::sections::section_display_title( + section.section.schema_title(), + crate::strategies::settings::human_labels(&self.backend.read(cx).layout), + ); + let entry_on = data.is_some_and(|d| d.entry_modelled()); + let keys: Vec<&'static str> = section + .knobs() + .filter(|k| knob_ticks(k, entry_on)) + .map(|k| k.key) + .collect(); + let (all_on, some_on) = self.ticks_tick_state(&keys); + let mut notes: Vec<(String, u32)> = Vec::new(); + match data { + // Only a LOADED empty scope says so; a load in flight or a failed one has its own + // note in the table. + Some(d) if d.kinds.is_empty() => { + if first { + notes.push((t!("analytics.ticks.no_deals").to_string(), p.text_muted)); + } + } Some(d) => { - let (hits, n) = d.share_of(group); - match d.group_passes(group, gate) { - Some(false) => Some(( - t!( - "analytics.ticks.vary_gated", - hits = hits, - n = n, - gate = (gate * 100.0).round() as i64 - ) - .to_string(), - p.orange, - )), - None => Some((t!("analytics.ticks.vary_unknown").to_string(), p.text_muted)), - Some(true) => None, + for group in [ParamGroup::Entry, ParamGroup::Exit] { + if section.knobs().any(|k| k.group == group) && !noted.contains(&group) { + noted.push(group); + notes.extend(self.ticks_group_note(group, d, p)); + } } } - None => None, + None => {} + } + let color = if notes.iter().any(|(_, c)| *c == p.orange) { + p.orange + } else { + p.text_muted }; + let note = (!notes.is_empty()).then(|| { + notes + .into_iter() + .map(|(text, _)| text) + .collect::>() + .join(" · ") + }); + let id = format!("{:?}", section.section); + let which = section.section; + let collapsed = !self.ticks.open_sections.contains(&which); h_flex() .w_full() .px(design::ui_px(cx, 8.0)) @@ -251,11 +395,49 @@ impl AnalyticsView { .border_color(moon_alpha(p.border, 0.7)) .text_size(design::t_caption(cx)) .font_family(design::ui_font()) - .child(div().flex_none().text_color(moon(p.text_soft)).child(title)) - .when_some(note, |el, (note, color)| { + .child(collapse_caret( + SharedString::from(format!("an-ticks-sec-caret-{id}")), + collapsed, + t!("analytics.ticks.section_collapse").to_string(), + t!("analytics.ticks.section_expand").to_string(), + p, + cx.listener(move |this, _, _, cx| this.ticks_toggle_section(which, cx)), + )) + .child( + div().flex_none().child( + MoonCheckbox::new(SharedString::from(format!("an-ticks-sec-{id}"))) + .checked(all_on) + .indeterminate(some_on) + .disabled(keys.is_empty()) + .size(design::CONTROL_TIER) + .on_change({ + let view = cx.entity(); + let keys = keys.clone(); + move |on: &bool, _w, app| { + let on = *on; + view.update(app, |this, cx| this.ticks_set_all(&keys, on, cx)); + } + }), + ), + ) + // The name folds the section too, as a heading row does; the body step marks it + // above the caption-sized rows it holds. + .child( + div() + .id(SharedString::from(format!("an-ticks-sec-title-{id}"))) + .flex_none() + .cursor_pointer() + .text_size(design::t_body(cx)) + .text_color(moon(p.text)) + .child(title) + .on_click( + cx.listener(move |this, _, _, cx| this.ticks_toggle_section(which, cx)), + ), + ) + .when_some(note, |el, note| { el.child( div() - .id(SharedString::from(format!("an-ticks-grp-note-{group:?}"))) + .id(SharedString::from(format!("an-ticks-sec-note-{id}"))) .flex_1() .min_w_0() .truncate() @@ -267,6 +449,84 @@ impl AnalyticsView { .into_any_element() } + /// A field the search does not turn: a greyed, disabled tick, the name with why in its + /// tooltip, the strategies' value, and blanks where the variant cells stand, so the columns + /// stay in line. + fn ticks_fixed_row( + &self, + key: &str, + role: RowRole, + now: Option, + data: Option<&TicksData>, + p: MoonPalette, + cx: &Context, + ) -> AnyElement { + let (tip, name_color) = match role { + RowRole::Fixed => (t!("analytics.ticks.row_fixed").to_string(), p.text_soft), + RowRole::Outside => (t!("analytics.ticks.row_outside").to_string(), p.text_muted), + // A knob of the entry group while a kind of the scope has no entry model. + RowRole::Knob(_) => ( + t!( + "analytics.ticks.entry_from_fact", + kinds = data + .map(|d| d.unmodelled_kinds().join(", ")) + .unwrap_or_default() + ) + .to_string(), + p.text_soft, + ), + }; + let value = match now { + Some(NowValue::Same(value)) if !value.is_empty() => value, + Some(NowValue::Differs) => t!("analytics.time.cur_varies").to_string(), + _ => "—".to_string(), + }; + let mut row = h_flex() + .w_full() + .px(design::ui_px(cx, 8.0)) + .pl(design::ui_px(cx, ROW_INDENT)) + .py(design::ui_px(cx, 2.0)) + .items_center() + .gap(design::ui_px(cx, 6.0)) + .border_t_1() + .border_color(moon_alpha(p.border, 0.5)) + .text_size(design::t_caption(cx)) + .child( + div().flex_none().child( + MoonCheckbox::new(SharedString::from(format!("an-ticks-fx-en-{key}"))) + .checked(false) + .disabled(true) + .size(design::CONTROL_TIER), + ), + ) + .child( + div() + .id(SharedString::from(format!("an-ticks-fx-{key}"))) + .flex_1() + .min_w_0() + .truncate() + .text_color(moon(name_color)) + .tooltip(crate::panels::common::text_tooltip(tip)) + .child(key.to_string()), + ) + .child( + div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .truncate() + .text_right() + .text_color(moon_alpha(p.text_muted, 0.8)) + .child(value), + ); + for _ in 0..N_VAR { + row = row + .child(div().w(design::font_w_px(cx, CELL_W)).flex_none()) + .child(div().w(design::ui_px(cx, 12.0)).flex_none()) + .child(div().w(design::ui_px(cx, 12.0)).flex_none()); + } + row.into_any_element() + } + /// One field: its tick, its name, the strategies' value, an input per variant with its /// clear cross. fn ticks_field_row( @@ -287,12 +547,13 @@ impl AnalyticsView { .id(SharedString::from(format!("an-ticks-field-{key}"))) .w_full() .px(design::ui_px(cx, 8.0)) + .pl(design::ui_px(cx, ROW_INDENT)) .py(design::ui_px(cx, 2.0)) .items_center() .gap(design::ui_px(cx, 6.0)) .border_t_1() .border_color(moon_alpha(p.border, 0.5)) - .text_size(design::t_body(cx)) + .text_size(design::t_caption(cx)) .when(selected, |el| el.bg(moon_alpha(p.amber, 0.08))) .child( div().flex_none().child( @@ -392,7 +653,7 @@ impl AnalyticsView { .child( MoonInput::new(SharedString::from(format!("an-ticks-in-v{vi}-{key}"))) .state(input) - .size(design::INPUT_SIZE), + .size(design::dense_input_size(cx)), ), ) // Under the header's copy arrow. @@ -469,16 +730,15 @@ impl AnalyticsView { } } -/// The fields the scope's kinds understand — the union over the kinds present, in descriptor -/// order. -fn scope_fields(data: Option<&TicksData>) -> Vec<&'static TickParam> { - let kinds: Vec = data.map(|d| d.kinds.clone()).unwrap_or_default(); - moon_core::db::tuner::ticks::TICK_PARAMS - .iter() - .filter(|f| { - kinds - .iter() - .any(|k| params_for(f.group, k).any(|g| g.key == f.key)) - }) - .collect() +/// Whether a knob is drawn live: an Entry knob only while every kind of the scope has an entry +/// model, as the search varies it only then. +fn knob_live(knob: &TickParam, entry_on: bool) -> bool { + knob.group != ParamGroup::Entry || entry_on +} + +/// Whether a section's or the header's tick counts and toggles a knob: a live one the entry +/// method reads. A field it does not read is drawn unticked whatever `locked` says, and counting +/// it would leave the tick half-set with every visible box ticked. +fn knob_ticks(knob: &TickParam, entry_on: bool) -> bool { + knob_live(knob, entry_on) && super::model_cfg::current().entry_method.reads(knob.key) } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 205e84d3..cb21c422 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -52,6 +52,13 @@ type StageA = ( OwnValues, ); +/// What stage B publishes beside the rows: the strategies' values and the grid's layout. +struct ScopeView { + now: HashMap, + own: OwnValues, + grid: Arc<[super::sections::GridSection]>, +} + impl AnalyticsView { /// Recompute the axis for the current scope. pub(in crate::analytics) fn reload_ticks(&mut self, cx: &mut Context) { @@ -99,7 +106,22 @@ impl AnalyticsView { let report_req = self.current_report_generation(); let q = self.tuner_query(); let targets: Vec<(i64, Option)> = self.visible_target_keys(self.read_core_ids()); - let keys = params::param_keys(); + // The models' fields, and every field the grid's sections draw fixed beside them, so + // those rows show the strategies' values too. The models read theirs by name; the extra + // keys only ride along in the maps. The schema signature comes off the same store read: + // a schema that moves on after it makes the grid ask for another load (`grid.rs`), whose + // keys then cover it. + let mut keys = params::param_keys(); + self.ticks.keys_sig = Some({ + let backend = self.backend.read(cx); + let store = backend.session.store(); + for key in super::sections::schema_keys(store) { + if !keys.contains(&key) { + keys.push(key); + } + } + super::sections::schema_signature(store) + }); self.ticks.data.begin(); self.ticks.kpi.begin(); self.spawn_latest_db( @@ -160,13 +182,18 @@ impl AnalyticsView { return; } }; + let grid = this.grid_layout(&read.deals, cx); let addresses = this.resolve_addresses(&read.deals, cx); this.start_replay_stage( req, report_req, after_report, read, - (now, own), + ScopeView { + now, + own, + grid, + }, addresses, cx, ); @@ -174,6 +201,28 @@ impl AnalyticsView { ); } + /// The grid's rows for the deals' kinds, by section: the fields the live schema files under + /// each of the kinds, the knobs no schema places under their own section. + fn grid_layout( + &self, + deals: &[Deal], + cx: &Context, + ) -> Arc<[super::sections::GridSection]> { + let mut kinds: Vec = Vec::new(); + for deal in deals { + if !kinds.contains(&deal.kind) { + kinds.push(deal.kind.clone()); + } + } + let knobs = super::sections::scope_knobs(&kinds); + let backend = self.backend.read(cx); + let schema = super::sections::scope_schema( + backend.session.store(), + deals.iter().map(|d| (d.strategy_id, d.core_uid)), + ); + super::sections::layout(&schema, &knobs).into() + } + /// Where each deal's prints live, per distinct `(core, coin)` — the fetch's own resolver, /// asked once per distinct pair. A core that is not connected, or a coin its catalog does /// not spell, resolves to nothing and the row says so. @@ -204,7 +253,7 @@ impl AnalyticsView { report_req: u64, after_report: bool, read: DealsRead, - (now, own): (HashMap, OwnValues), + scope: ScopeView, addresses: HashMap<(u64, String), Option>>, cx: &mut Context, ) { @@ -276,8 +325,9 @@ impl AnalyticsView { entry_share: (0, 0), exit_share: (0, 0), kinds, - now, - own, + now: scope.now, + own: scope.own, + grid: scope.grid, }; data.retain_within_cap(); data.refresh_summary(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index f747760e..d73356b5 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -44,6 +44,7 @@ mod lags; mod load; pub(in crate::analytics) mod model_cfg; pub(in crate::analytics::tuner) mod rows; +mod sections; pub(in crate::analytics) mod state; mod tape; mod trade_pane; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs new file mode 100644 index 00000000..5eab99e6 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs @@ -0,0 +1,212 @@ +//! The rows of the Entry/Exit grid, laid out by the strategy editor's sections — Strategy +//! settings, Stops, Sell order, SellShot, SellSpread, Delta Modifiers — with EVERY field each +//! section holds for the scope's kinds, as the Strategies window shows them. Only the knobs of +//! [`TICK_PARAMS`](moon_core::db::tuner::ticks::TICK_PARAMS) are searched; every other field is +//! drawn fixed, so what the model does not turn yet stays in sight where the user looks for it. +//! +//! The field lists come from the live schema of each deal's strategy kind — the store's strategy +//! row gives the kind ordinal, as `strategies::logic::selected_sections` does; the `SignalType` +//! the deals carry is spelled differently from the schema's kind names (`PumpsDetection`). With +//! no connected core holding a schema, only the knobs are drawn, under their +//! [`TickParam::section`]. + +use std::collections::{HashMap, HashSet}; + +use moon_core::db::tuner::ticks::params::{ParamSection, TickParam, is_model_only, params_for}; +use moon_core::feed::SchemaSection; +use moon_core::session::CoreStore; + +use crate::strategies::sections::section_title_eq; + +#[cfg(test)] +mod tests; + +/// What a row of the grid is to the model. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::analytics::tuner) enum RowRole { + /// A field the search can turn. + Knob(&'static TickParam), + /// A field the model reads at the strategy's value and does not turn. + Fixed, + /// A field the model does not take into account. + Outside, +} + +/// One row: the field as the schema spells it, and its role. +#[derive(Clone, Debug, PartialEq)] +pub(in crate::analytics::tuner) struct GridRow { + pub(in crate::analytics::tuner) key: String, + pub(in crate::analytics::tuner) role: RowRole, +} + +/// One section of the grid with its rows in the schema's order. +#[derive(Clone, Debug, PartialEq)] +pub(in crate::analytics::tuner) struct GridSection { + pub(in crate::analytics::tuner) section: ParamSection, + pub(in crate::analytics::tuner) rows: Vec, +} + +impl GridSection { + /// The knobs of the section. + pub(in crate::analytics::tuner) fn knobs( + &self, + ) -> impl Iterator + '_ { + self.rows.iter().filter_map(|r| match r.role { + RowRole::Knob(p) => Some(p), + _ => None, + }) + } +} + +/// The knobs the scope's kinds understand — the union over the kinds present, in descriptor +/// order. +pub(in crate::analytics::tuner) fn scope_knobs(kinds: &[String]) -> Vec<&'static TickParam> { + moon_core::db::tuner::ticks::TICK_PARAMS + .iter() + .filter(|f| { + kinds + .iter() + .any(|k| params_for(f.group, k).any(|g| g.key == f.key)) + }) + .collect() +} + +/// The grid's sections, every one of [`ParamSection::GRID_ORDER`] in that order, empty ones +/// included. +/// +/// A field goes where the first kind's schema that has it files it; a field two sections share +/// is drawn once. A knob no schema places goes under its own [`TickParam::section`]. +/// +/// Args: +/// kind_sections: The schema sections of each kind in the scope. +/// knobs: The knobs of the scope ([`scope_knobs`]). +pub(in crate::analytics::tuner) fn layout( + kind_sections: &[&[SchemaSection]], + knobs: &[&'static TickParam], +) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut out: Vec = ParamSection::GRID_ORDER + .iter() + .map(|§ion| GridSection { + section, + rows: Vec::new(), + }) + .collect(); + for grid in &mut out { + let title = grid.section.schema_title(); + for sections in kind_sections { + for section in sections + .iter() + .filter(|s| section_title_eq(&s.title, title)) + { + for field in §ion.fields { + if seen.insert(field.name.to_ascii_lowercase()) { + grid.rows.push(row(&field.name, knobs)); + } + } + } + } + } + for &knob in knobs { + if seen.insert(knob.key.to_ascii_lowercase()) + && let Some(grid) = out.iter_mut().find(|g| g.section == knob.section) + { + grid.rows.push(GridRow { + key: knob.key.to_string(), + role: RowRole::Knob(knob), + }); + } + } + out +} + +/// A schema field's row: a knob of the scope, a field the model reads, or one it does not. +fn row(name: &str, knobs: &[&'static TickParam]) -> GridRow { + let role = match knobs.iter().find(|k| k.key.eq_ignore_ascii_case(name)) { + Some(&knob) => RowRole::Knob(knob), + None if is_model_only(name) => RowRole::Fixed, + None => RowRole::Outside, + }; + let key = match role { + RowRole::Knob(knob) => knob.key.to_string(), + _ => name.to_string(), + }; + GridRow { key, role } +} + +/// The schema sections of each distinct kind the strategies of `pairs` are, by +/// `(strategy_id, core_uid)`. A strategy on a core without a schema, or no longer in the core's +/// list, gives nothing. +pub(in crate::analytics::tuner) fn scope_schema( + store: &CoreStore, + pairs: impl IntoIterator, +) -> Vec<&[SchemaSection]> { + let mut by_core: HashMap> = HashMap::new(); + for (strategy, core) in pairs { + by_core.entry(core).or_default().insert(strategy); + } + let mut kinds: HashSet<(u64, u8)> = HashSet::new(); + let mut out = Vec::new(); + for (core, strategies) in by_core { + let Some(data) = store.core(core) else { + continue; + }; + let Some(schema) = data.schema.as_ref() else { + continue; + }; + for row in &data.strategies { + if !strategies.iter().any(|&id| same_strategy(row.id, id)) { + continue; + } + if !kinds.insert((core, row.kind_ordinal)) { + continue; + } + if let Some(kind) = schema.kinds.iter().find(|k| k.ordinal == row.kind_ordinal) { + out.push(kind.sections.as_slice()); + } + } + } + out +} + +/// Every field name the grid's sections hold in any kind of any connected core's schema — the +/// keys the "now" column reads beside the models' own, so a fixed row shows the strategy's value. +pub(in crate::analytics::tuner) fn schema_keys(store: &CoreStore) -> Vec { + let mut seen: HashSet<&str> = HashSet::new(); + for (_, core) in store.cores() { + let Some(schema) = core.schema.as_ref() else { + continue; + }; + for kind in &schema.kinds { + for section in kind.sections.iter().filter(|s| { + ParamSection::GRID_ORDER + .iter() + .any(|g| section_title_eq(&s.title, g.schema_title())) + }) { + seen.extend(section.fields.iter().map(|f| f.name.as_str())); + } + } + } + seen.into_iter().map(str::to_string).collect() +} + +/// A signature of the schemas the store holds: which cores have one, at which revision. It moves +/// whenever a core's schema arrives, changes or goes, and is independent of the order the store +/// lists its cores in. +pub(in crate::analytics::tuner) fn schema_signature(store: &CoreStore) -> u64 { + store + .cores() + .filter(|(_, core)| core.schema.is_some()) + .map(|(id, core)| { + (id ^ core.schema_rev.rotate_left(32)).wrapping_mul(0x9E37_79B9_7F4A_7C15) + }) + .fold(0u64, u64::wrapping_add) +} + +/// Whether a store strategy row (`id` as the core sends it) is the report's `strategyid`. +/// +/// The report keeps the core's `u64` in a signed column, so an id past `i64::MAX` reads back +/// negative: the bits are the same, the value is not (as `tuner/mod.rs` maps `live_id`). +fn same_strategy(row_id: u64, strategy_id: i64) -> bool { + row_id == strategy_id as u64 +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs new file mode 100644 index 00000000..04e75074 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs @@ -0,0 +1,146 @@ +use super::*; +use moon_core::feed::{SchemaField, SchemaFieldUi}; + +fn section(title: &str, names: &[&str]) -> SchemaSection { + SchemaSection { + title: title.to_string(), + fields: names + .iter() + .map(|name| SchemaField { + name: name.to_string(), + type_name: "Double".to_string(), + ui: SchemaFieldUi::Edit, + picklist: Vec::new(), + default: None, + }) + .collect(), + } +} + +fn keys(grid: &GridSection) -> Vec<&str> { + grid.rows.iter().map(|r| r.key.as_str()).collect() +} + +fn find(out: &[GridSection], s: ParamSection) -> &GridSection { + out.iter().find(|g| g.section == s).expect("section") +} + +#[test] +fn every_section_comes_out_in_grid_order() { + let out = layout(&[], &[]); + let order: Vec = out.iter().map(|g| g.section).collect(); + assert_eq!(order, ParamSection::GRID_ORDER); + assert!(out.iter().all(|g| g.rows.is_empty())); +} + +#[test] +fn the_schema_places_every_field_and_marks_what_the_model_turns() { + let knobs = scope_knobs(&["MoonShot".to_string()]); + // The core's own spellings: a backslash and a doubled space do not split a section off. + let kind = vec![ + section( + "Strategy settings", + &["MShotPrice", "MShotRepeatWait", "MShotSellAtLastPrice"], + ), + section( + "Sell order\\SellShot", + &["IgnoreSellShot", "SellShotPriceDown"], + ), + section( + "Stops", + &["UseStopLoss", "StopLoss", "UseTrailing", "TrailingPercent"], + ), + section("Filters", &["MinVolume"]), + ]; + let out = layout(&[kind.as_slice()], &knobs); + + let settings = find(&out, ParamSection::StrategySettings); + assert_eq!( + keys(settings)[..3], + ["MShotPrice", "MShotRepeatWait", "MShotSellAtLastPrice"] + ); + assert!(matches!(settings.rows[0].role, RowRole::Knob(p) if p.key == "MShotPrice")); + assert_eq!(settings.rows[1].role, RowRole::Outside); + + let shot = find(&out, ParamSection::SellShot); + assert_eq!(keys(shot)[..2], ["IgnoreSellShot", "SellShotPriceDown"]); + // Read by the SellShot walk, never turned. + assert_eq!(shot.rows[1].role, RowRole::Fixed); + // The knobs this schema left out follow under their own section. + assert!( + shot.rows[2..] + .iter() + .all(|r| matches!(r.role, RowRole::Knob(p) if p.section == ParamSection::SellShot)) + ); + assert!(shot.rows.iter().any(|r| r.key == "SellShotDistance")); + + let stops = find(&out, ParamSection::Stops); + assert_eq!( + keys(stops)[..4], + ["UseStopLoss", "StopLoss", "UseTrailing", "TrailingPercent"] + ); + assert_eq!(stops.rows[0].role, RowRole::Fixed); + assert_eq!(stops.rows[3].role, RowRole::Outside); + + // A section outside the grid is not drawn. + assert!( + out.iter() + .flat_map(|g| g.rows.iter()) + .all(|r| r.key != "MinVolume") + ); +} + +#[test] +fn a_knob_the_schema_does_not_place_goes_under_its_own_section_once() { + let knobs = scope_knobs(&["MoonShot".to_string()]); + let out = layout(&[], &knobs); + for knob in &knobs { + let at: Vec = out + .iter() + .filter(|g| g.rows.iter().any(|r| r.key == knob.key)) + .map(|g| g.section) + .collect(); + assert_eq!(at, [knob.section], "{}", knob.key); + } + // With the schema, the field keeps the schema's place and is not drawn a second time. + let kind = vec![section("Sell order", &["SellPrice"])]; + let out = layout(&[kind.as_slice()], &knobs); + let placed: usize = out + .iter() + .map(|g| g.rows.iter().filter(|r| r.key == "SellPrice").count()) + .sum(); + assert_eq!(placed, 1); +} + +#[test] +fn two_kinds_share_a_section_without_repeating_a_field() { + let knobs = scope_knobs(&["MoonShot".to_string(), "MoonHook".to_string()]); + let shot = vec![section("Stops", &["UseStopLoss", "StopLoss"])]; + let hook = vec![section("Stops", &["StopLoss", "StopLossDelay"])]; + let out = layout(&[shot.as_slice(), hook.as_slice()], &knobs); + assert_eq!( + keys(find(&out, ParamSection::Stops)), + ["UseStopLoss", "StopLoss", "StopLossDelay"] + ); +} + +#[test] +fn a_knob_another_kind_has_is_outside_for_a_kind_that_does_not_read_it() { + // A MoonHook's take is `HookSellLevel`; `SellPrice` moves nothing for it. + let knobs = scope_knobs(&["MoonHook".to_string()]); + let kind = vec![section("Sell order", &["SellPrice", "SellDelay"])]; + let out = layout(&[kind.as_slice()], &knobs); + let sell = find(&out, ParamSection::SellOrder); + assert_eq!(sell.rows[0].role, RowRole::Outside); + assert!(matches!(sell.rows[1].role, RowRole::Knob(p) if p.key == "SellDelay")); +} + +#[test] +fn a_strategy_id_past_i64_max_is_the_reports_negative_one() { + // The report stores the core's u64 id as a signed column: HookTestO1 on GateF is + // -7944420346259379305 there (2026-09-24). + let report: i64 = -7_944_420_346_259_379_305; + assert!(same_strategy(report as u64, report)); + assert!(same_strategy(42, 42)); + assert!(!same_strategy(42, 43)); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index c7986aed..c98ad49b 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -17,7 +17,7 @@ use super::tape::{PackedTape, PendingDeal}; use crate::load_state::LoadState; use moon_core::db::tuner::VarStats; use moon_core::db::tuner::threshold_search::SearchHandle; -use moon_core::db::tuner::ticks::params::ParamGroup; +use moon_core::db::tuner::ticks::params::{ParamGroup, ParamSection}; use moon_core::db::tuner::ticks::search::SearchResult; use moon_core::db::tuner::ticks::{Deal, Verdict, fit_for_search}; use moon_core::market::trade_replay::TickStatus; @@ -170,6 +170,9 @@ pub(in crate::analytics::tuner) struct TicksData { /// ([`PreparedDeal::own`]). The grid folds these into one "now" cell; a replay must not, /// or a field the strategies disagree on runs every deal at its default. pub(in crate::analytics::tuner) own: OwnValues, + /// The parameter grid's rows by section (`sections::layout`), published with the rows so a + /// layout never meets another scope's "now" values or kinds. + pub(in crate::analytics::tuner) grid: Arc<[super::sections::GridSection]>, } /// What [`TicksData::tape_budget`] counts. @@ -397,6 +400,15 @@ pub(in crate::analytics) struct TicksState { /// no trade of, or one outside the replayed sample. Scored with the columns, cleared with /// them. pub(in crate::analytics::tuner) plan: [HashMap; N_VAR], + /// `sections::schema_signature` of the store when the latest load chose the keys of its + /// "now" values — set when the load is ASKED, so a load already running under a new schema + /// is not asked for again (`grid.rs`). + pub(in crate::analytics::tuner) keys_sig: Option, + /// The schema signature a reload was last asked for because the latest load chose its keys + /// under another; one ask per signature, so a failed reload is not retried every frame. + pub(in crate::analytics::tuner) schema_reload: Option, + /// The grid's sections the user opened; every section starts folded (`grid.rs`). + pub(in crate::analytics::tuner) open_sections: HashSet, } impl Default for TicksState { @@ -439,6 +451,9 @@ impl Default for TicksState { tape_seq: 0, trade: Default::default(), plan: Default::default(), + keys_sig: None, + schema_reload: None, + open_sections: HashSet::new(), } } } diff --git a/crates/moon-ui-gpui/src/design.rs b/crates/moon-ui-gpui/src/design.rs index 185644a6..aa351920 100644 --- a/crates/moon-ui-gpui/src/design.rs +++ b/crates/moon-ui-gpui/src/design.rs @@ -697,6 +697,28 @@ pub const BODY_LG_STEP: f32 = 1.0; /// reviewed design measured every such field at. pub const INPUT_SIZE: MoonInputSize = MoonInputSize::Small; +/// The `MoonInput` size of a cell in a dense grid whose rows carry caption text: the +/// [`MoonSize::Xs`] tier's metrics — the tier caption text belongs to — with the text at +/// [`t_caption`] and no vertical padding, so the box is one caption line tall and a row holding +/// it stands as high as its text-only neighbours. +/// +/// `height` is the tier's LINE height, not 0: the input draws its box from the size `height` +/// selects, never from `Custom`'s own `h` (that lands on the multi-line height, +/// `docs-internal/FORK_BUGS.md`), and the box comes out one scaled line tall — 19 px against a +/// 19.5 px caption line at the design scale (`design/tests.rs`). +pub fn dense_input_size(cx: &App) -> MoonInputSize { + let m = MoonSize::Xs.control_metrics(); + MoonInputSize::Custom { + height: m.line_height, + radius: m.radius, + font_size: font_base_for(cx, f32::from(t_caption(cx))), + line_height: m.line_height, + pad_x: m.pad_x, + pad_y: 0.0, + gap: m.gap, + } +} + pub fn ui_value(cx: &App, value: f32) -> f32 { MoonTheme::active_tokens(cx).ui(value) } diff --git a/crates/moon-ui-gpui/src/design/tests.rs b/crates/moon-ui-gpui/src/design/tests.rs index 926d64d4..c29306b2 100644 --- a/crates/moon-ui-gpui/src/design/tests.rs +++ b/crates/moon-ui-gpui/src/design/tests.rs @@ -435,3 +435,89 @@ fn body_font_base_round_trips_through_the_font_channel(cx: &mut gpui::TestAppCon }); assert!((rendered - body).abs() < 0.01, "{rendered} != {body}"); } + +/// A headless host drawing a caption text line beside a grid cell with the dense input and one +/// with the ordinary small input, so their laid-out heights can be read back. +struct InputProbe { + dense: Option>, + small: Option>, +} + +impl gpui::Render for InputProbe { + fn render( + &mut self, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + use gpui::{AppContext, InteractiveElement, ParentElement, Styled, div, px}; + let mut state = |slot: &mut Option>| { + slot.get_or_insert_with(|| { + cx.new(|cx| moon_ui::MoonInputState::new(window, cx).default_value("0.1")) + }) + .clone() + }; + let dense = state(&mut self.dense); + let small = state(&mut self.small); + let cell = |id: &'static str, input: moon_ui::MoonInput| { + div() + .debug_selector(move || id.into()) + .w(px(60.0)) + .flex_none() + .child(input) + }; + moon_ui::h_flex() + .items_center() + .child( + div() + .debug_selector(|| "caption".into()) + .flex_none() + .text_size(super::t_caption(cx)) + .child("SellShotDelay"), + ) + .child(cell( + "dense", + moon_ui::MoonInput::new("dense") + .state(&dense) + .size(super::dense_input_size(cx)), + )) + .child(cell( + "small", + moon_ui::MoonInput::new("small") + .state(&small) + .size(super::INPUT_SIZE), + )) + } +} + +/// `design::dense_input_size` must lay out as tall as a caption text line: the tuner's +/// Entry/Exit grid puts it in rows beside text-only rows. `MoonInputSize::Custom`'s `height` +/// reaches only the size the input computes its box from — its own `h` goes to the multi-line +/// height — so `height: 0` drew a 3 px strip (2026-09-24) while every unit read of the code said +/// 19 px. +#[gpui::test] +fn the_dense_input_stands_as_tall_as_a_caption_line(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + moon_ui::MoonTheme::install_config( + crate::startup::moon_theme_config_for_presentation(UiThemeMode::Dark, 1.0), + cx, + ); + }); + let window = cx.add_window(|_, _| InputProbe { + dense: None, + small: None, + }); + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + visual.update(|window, cx| { + let _ = window.draw(cx); + }); + let mut height = |id| f32::from(visual.debug_bounds(id).expect("laid out").size.height); + let (caption, dense, small) = (height("caption"), height("dense"), height("small")); + assert!( + (dense - caption).abs() <= 1.0, + "dense input {dense}px against a caption line of {caption}px" + ); + assert!( + dense < small, + "dense {dense}px is no denser than small {small}px" + ); +} diff --git a/crates/moon-ui-gpui/src/strategies/mod.rs b/crates/moon-ui-gpui/src/strategies/mod.rs index 1a5ab403..5a1f9a4b 100644 --- a/crates/moon-ui-gpui/src/strategies/mod.rs +++ b/crates/moon-ui-gpui/src/strategies/mod.rs @@ -18,10 +18,10 @@ pub(crate) mod logic; mod param_entries; mod params; mod rules; -mod sections; +pub(crate) mod sections; mod selection; mod session; -mod settings; +pub(crate) mod settings; mod split; mod state; // `pub(crate)` exposes `unique_name`, `set_field`, and `STRATEGY_NAME_FIELD` to the Analytics diff --git a/crates/moon-ui-gpui/src/strategies/sections.rs b/crates/moon-ui-gpui/src/strategies/sections.rs index 7905da48..e6fbbf49 100644 --- a/crates/moon-ui-gpui/src/strategies/sections.rs +++ b/crates/moon-ui-gpui/src/strategies/sections.rs @@ -63,7 +63,7 @@ const SECTION_LABELS: &[(&str, &str)] = &[ /// /// Returns: /// Whether the two titles name the same section. -fn section_title_eq(a: &str, b: &str) -> bool { +pub(crate) fn section_title_eq(a: &str, b: &str) -> bool { let separator = |c: char| c == '/' || c == '\\'; let mut left = a.split(separator); let mut right = b.split(separator); @@ -113,7 +113,7 @@ pub(super) fn section_label_key(raw_title: &str) -> Option<&'static str> { /// /// Returns: /// `" · "` when a label exists and labels are on, or `raw_title` unchanged. -pub(super) fn section_display_title(raw_title: &str, human_labels: bool) -> String { +pub(crate) fn section_display_title(raw_title: &str, human_labels: bool) -> String { match section_label_key(raw_title).filter(|_| human_labels) { Some(key) => format!("{raw_title} · {}", t!(key)), None => raw_title.to_string(), diff --git a/crates/moon-ui-gpui/src/strategies/settings.rs b/crates/moon-ui-gpui/src/strategies/settings.rs index 516df874..f53f5f62 100644 --- a/crates/moon-ui-gpui/src/strategies/settings.rs +++ b/crates/moon-ui-gpui/src/strategies/settings.rs @@ -160,6 +160,12 @@ const HUMAN_LABELS: PrefRow = PrefRow { store: |layout, value| layout.strategies_human_labels = Some(value), }; +/// The human-labels preference as the layout holds it, for a section heading drawn outside the +/// Strategies window — the tuner's Entry/Exit grid names its sections as this window does. +pub(crate) fn human_labels(layout: &moon_core::config::WindowLayout) -> bool { + (HUMAN_LABELS.saved)(layout).unwrap_or_else(|| StrategiesPrefs::default().human_labels) +} + /// Every preference, in the order `restore` resolves them. Persistence covers all of them wherever /// their control lives. const PREF_ROWS: [&PrefRow; 4] = [&GROUP_BY_VENUE, &ACTIVE_ONLY, &PARAMS_FULL, &HUMAN_LABELS]; diff --git a/locales/analytics.yml b/locales/analytics.yml index 7b9445ac..8c08d3fd 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1920,6 +1920,22 @@ analytics.ticks.entry_from_fact: ru: "вход для %{kinds} не моделируется — берётся из факта" en: "the entry of %{kinds} is not modelled — taken from the fact" es: "la entrada de %{kinds} no se modela — se toma del hecho" +analytics.ticks.section_collapse: + ru: "Свернуть секцию" + en: "Fold the section" + es: "Plegar la sección" +analytics.ticks.section_expand: + ru: "Развернуть секцию" + en: "Open the section" + es: "Abrir la sección" +analytics.ticks.row_fixed: + ru: "Модель берёт значение стратегии — в переборе не участвует" + en: "The model reads the strategy's value — it is not searched" + es: "El modelo lee el valor de la estrategia — no entra en la búsqueda" +analytics.ticks.row_outside: + ru: "Модель это поле пока не учитывает" + en: "The model does not take this field into account yet" + es: "El modelo aún no tiene en cuenta este campo" analytics.ticks.no_deals: ru: "в выборке нет сделок с мс-штампами" en: "no trades with millisecond stamps in the scope" From d3633070ae4d4b4f23839aaa0a18465af5111178 Mon Sep 17 00:00:00 2001 From: guyverino Date: Thu, 24 Sep 2026 13:26:40 +0200 Subject: [PATCH 34/51] feat(tuner): split the exit by the Strategies window's sections, model 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. --- crates/moon-core/src/config/layout/tests.rs | 2 +- .../moon-core/src/db/tuner/ticks/calibrate.rs | 2 +- crates/moon-core/src/db/tuner/ticks/exit.rs | 377 ++----- .../src/db/tuner/ticks/exit/delta_mods.rs | 39 + .../moon-core/src/db/tuner/ticks/exit/line.rs | 295 ++++++ .../src/db/tuner/ticks/exit/line/tests.rs | 350 +++++++ .../src/db/tuner/ticks/exit/pump_move.rs | 75 ++ .../db/tuner/ticks/exit/pump_move/tests.rs | 34 + .../src/db/tuner/ticks/exit/sell_order.rs | 383 ++++++++ .../db/tuner/ticks/exit/sell_order/tests.rs | 195 ++++ .../src/db/tuner/ticks/exit/sell_shot.rs | 118 +++ .../db/tuner/ticks/exit/sell_shot/tests.rs | 45 + .../src/db/tuner/ticks/exit/sell_spread.rs | 5 + .../src/db/tuner/ticks/exit/stops.rs | 502 ++++++++++ .../src/db/tuner/ticks/exit/stops/tests.rs | 278 ++++++ .../src/db/tuner/ticks/exit/stops/trailing.rs | 237 +++++ .../tuner/ticks/exit/stops/trailing/tests.rs | 230 +++++ .../src/db/tuner/ticks/exit/tests.rs | 70 ++ crates/moon-core/src/db/tuner/ticks/line.rs | 811 --------------- .../src/db/tuner/ticks/line/tests.rs | 920 ------------------ crates/moon-core/src/db/tuner/ticks/mod.rs | 4 +- crates/moon-core/src/db/tuner/ticks/params.rs | 35 +- crates/moon-core/src/db/tuner/ticks/record.rs | 30 +- .../src/db/tuner/ticks/record/tests.rs | 2 +- .../moon-core/src/db/tuner/ticks/settings.rs | 8 +- crates/moon-core/src/db/tuner/ticks/tests.rs | 15 +- .../src/db/tuner/ticks/tests/real_data.rs | 15 +- crates/moon-core/src/db/tuner/ticks/verify.rs | 101 +- .../analytics/tuner/ticks/sections/tests.rs | 22 +- locales/analytics.yml | 6 +- 30 files changed, 3127 insertions(+), 2079 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/line.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/line/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/pump_move.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/pump_move/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/sell_shot.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/sell_shot/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/sell_spread.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/stops.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/stops/trailing.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/stops/trailing/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/tests.rs delete mode 100644 crates/moon-core/src/db/tuner/ticks/line.rs delete mode 100644 crates/moon-core/src/db/tuner/ticks/line/tests.rs diff --git a/crates/moon-core/src/config/layout/tests.rs b/crates/moon-core/src/config/layout/tests.rs index 55212fa2..2d6c18be 100644 --- a/crates/moon-core/src/config/layout/tests.rs +++ b/crates/moon-core/src/config/layout/tests.rs @@ -2361,7 +2361,7 @@ fn the_ticks_axis_settings_round_trip_and_never_cost_the_layout() { assert_eq!(model.latency_ms, 150.0); assert_eq!( model.ticker_period_ms, - crate::db::tuner::ticks::line::TICKER_PERIOD_MS + crate::db::tuner::ticks::exit::stops::TICKER_PERIOD_MS ); let broken: WindowLayout = diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate.rs b/crates/moon-core/src/db/tuner/ticks/calibrate.rs index ce06efb5..e8a0e8fd 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate.rs @@ -14,7 +14,7 @@ use super::Deal; use super::exit::ExitParams; -use super::line::step_ms; +use super::exit::sell_order::step_ms; use super::verify::ArchivedExit; /// Fewest samples a core's lag is taken from; below it the core runs on the plain schedule. diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index d1057226..a083d99d 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -2,31 +2,100 @@ //! model for every strategy kind — after the entry filled, the exit of any strategy is a //! function of the sell-order rules and the tape. //! -//! The take-profit is `SellPrice` per cent above the fill for every kind but two: MoonHook, -//! whose take replaces it with `HookSellLevel` per cent of the trade's own detect depth -//! ([`super::hook`]), and Spread, whose take is the edge of the spread it detected — a level, -//! not a rule, taken as the core recorded it ([`take_is_recorded`]). The rules are moved by the -//! Delta-Modifier family (`SellModifier`). It is -//! raised by `MShotSellAtLastPrice` to -//! the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the 4-second-old ASK, i.e. before -//! the spike"; the model takes the ask the caller recovered from the order archive -//! (`Deal::pre_spike_ask`), else reads the last print at least -//! `ModelSettings::pre_spike_lookback_ms` ([`super::mshot::PRE_SPIKE_LOOKBACK_MS`] by default) -//! before the fill, since the tape has no book). From there the line moves under the strategy's -//! sell rules -//! — `PriceDown*`, `SellLevel*`, `SellShot*` — and the stop fires under `StopLoss*`; see -//! [`super::line`]. A position nothing closed inside the tape is [`ExitKind::OpenAtWindowEnd`]: -//! not a trade, whatever the core's exit was. +//! One file per section of the strategy window, in the window's order: [`stops`], +//! [`sell_order`] (the take, `SellDelay`, `PriceDown*`, `SellLevel*`), [`sell_shot`], +//! [`sell_spread`], [`delta_mods`] — and PumpsDetection's own [`pump_move`]. The step they share — +//! the walk over the tape, the price grid, the latency, the recorded replacements — is [`line`]. +//! A position nothing closed inside the tape is [`ExitKind::OpenAtWindowEnd`]: not a trade, +//! whatever the core's exit was. +//! +//! [`ExitKind::OpenAtWindowEnd`]: super::ExitKind::OpenAtWindowEnd + +pub mod delta_mods; +pub mod line; +pub mod pump_move; +pub mod sell_order; +pub mod sell_shot; +pub mod sell_spread; +pub mod stops; -use super::hook::{KIND_MOONHOOK, hook_take_pct}; -use super::line::{LineWalk, walk, walk_held}; +use self::line::{LineWalk, walk, walk_held}; use super::mshot::Modifiers; use super::settings::ModelSettings; use super::{Deal, Exit, Fill}; use crate::feed::types::Tick; +/// Which way the position profits, folding every "above/below the buy" into one sign. Every +/// section's rule is written for a long and mirrored for a short through it. +#[derive(Clone, Copy)] +struct Side { + long: bool, +} + +impl Side { + /// `pct` per cent over the buy in the PROFIT direction: above for a long, below for a + /// short. + fn over(self, base: f64, pct: f64) -> f64 { + if self.long { + base * (1.0 + pct / 100.0) + } else { + base * (1.0 - pct / 100.0) + } + } + + /// The take side of two levels — the higher for a long — i.e. farther in profit. + fn farther(self, a: f64, b: f64) -> f64 { + if self.long { a.max(b) } else { a.min(b) } + } + + /// The nearer of two levels in profit terms. + fn nearer(self, a: f64, b: f64) -> f64 { + if self.long { a.min(b) } else { a.max(b) } + } + + /// The extreme print in the profit direction over a run. + fn extreme(self, prices: impl Iterator) -> Option { + if self.long { + prices.reduce(f64::max) + } else { + prices.reduce(f64::min) + } + } + + /// The extreme print in the profit direction among `seen` stamped from `from` to `to`, both + /// included. + fn extreme_between(self, seen: &[Tick], from: i64, to: i64) -> Option { + self.extreme( + seen.iter() + .filter(|t| { + let tt = t.time_ms as i64; + tt >= from && tt <= to && t.price > 0.0 + }) + .map(|t| f64::from(t.price)), + ) + } + + /// Distance of `level` from `reference`, per cent, positive in the profit direction. + fn distance_pct(self, reference: f64, level: f64) -> f64 { + if reference <= 0.0 { + return 0.0; + } + let signed = if self.long { + level - reference + } else { + reference - level + }; + signed / reference * 100.0 + } +} + +/// A timer rule's next moment, when it is due by the print at `t_ms`. +fn due_by(next: Option, t_ms: i64) -> Option { + next.filter(|due| t_ms >= *due) +} + /// Sell-line parameters, in the strategy's own units (per cent, seconds; `SellDelay` is ms). -/// Every rule's fields are documented in [`super::line`]. +/// Every rule's fields are documented in its section's module. #[derive(Clone, Debug, PartialEq)] pub struct ExitParams { /// `SellPrice` — take-profit distance from the fill, per cent. @@ -116,15 +185,24 @@ pub struct ExitParams { /// `FastStopLoss` — what the stop watches. YES: the trades ("crosses", FAQ), so the first /// print through the level fires it. NO — the core's default: the REST ticker's BID (the /// ASK for a short), a long's averaged per `StopLossEMA`, which the trade tape does not - /// carry; the walk then reads a sampled proxy of it (see [`super::line`]). + /// carry; the walk then reads a sampled proxy of it (see [`stops`]). pub fast_stop_loss: bool, /// `StopLossEMA` — the non-fast stop's average of the ticker's BID, `(avg·(N − 1) + bid)/N` /// per arrival, kept for a LONG at 3, 5 or 10 only; any other value and every short watch the /// bare price, and at 0 the core's price series fires it too (the core developer, - /// 2026-09-23; see `line::stop_average_weight`). Ignored by a fast stop — the FAQ's own + /// 2026-09-23; see `stops::stop_average_weight`). Ignored by a fast stop — the FAQ's own /// distinction, and the live activations agree: 48 fast stops with it at 3 fire as promptly /// as 81 without it. pub stop_loss_ema: f64, + /// `TrailingPercent` when `UseTrailing` is on, 0 when it is off: how far under the peak of the + /// spread's middle the trailing line stands, per cent (negative). See [`stops`]. + pub trailing_pct: f64, + /// `TrailingEMA` — a step of the trailing peak moves `1/(N + 1)` of the way to the middle. + pub trailing_ema: f64, + /// `TakeProfit` when `UseTakeProfit` is on — the trailing's own take profit, per cent off the + /// buy, NOT the order's `SellPrice`: no line until the middle passed it by `|TrailingPercent|`, + /// and no sale below it. `None` when it is off. + pub trailing_take_profit_pct: Option, /// A sell rule the strategy switched on that the model does not have. The walk runs as if /// it were off, and the verdict answers nothing for such a trade, which keeps it out of the /// search (`record::fit_for_search`): a variant's exit there is whatever the missing rule @@ -187,6 +265,9 @@ impl Default for ExitParams { // its absence there is the core's default, NO. fast_stop_loss: true, stop_loss_ema: 0.0, + trailing_pct: 0.0, + trailing_ema: 0.0, + trailing_take_profit_pct: None, unmodelled: None, model: ModelSettings::default(), take_from_archive: false, @@ -197,8 +278,6 @@ impl Default for ExitParams { /// A sell rule the strategy can switch on that the model does not have. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum UnmodelledRule { - /// `UseTrailing` — the trailing stop. - Trailing, /// `UseSecondStop` / `UseStopLoss3` — the stop ladder. StopLadder, } @@ -213,116 +292,6 @@ impl<'a> ExitModel<'a> { Self { params } } - /// The take-profit level for a fill: `SellPrice` off the fill, lifted to the pre-spike - /// ask (the archive's, else the tape's last print) less the adjustment when - /// `MShotSellAtLastPrice` is on. Long above, short below. - pub fn take_level(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> f64 { - // A kind whose take rule the model does not have starts where the core's line did — - // when the fact is being judged; a variant computes the take from the rules for every - // kind (see `ExitParams::take_from_archive`) but the one whose take is not a rule at - // all ([`take_is_recorded`]). The recorded level is the core's own, modifiers and all, - // so nothing below is added to it. - if (self.params.take_from_archive && !take_model_for(&deal.kind)) - || take_is_recorded(&deal.kind) - { - if let Some(take) = deal.archived_take.filter(|t| t.is_finite() && *t > 0.0) { - return take; - } - } - // Floored at zero: a modifier deep enough to drive the distance negative would put the - // TAKE on the losing side of the entry and turn every level the line steps down from - // inside out. The rules that legitimately sell below the entry are the moving ones - // (`PriceDownAllowedDrop`, a negative `SellShotDistance`), and they get there by - // stepping down from the take, not by starting underneath it. - let pct = (self.base_take_pct(deal) + self.modifier_pct(deal, fill.t_ms)).max(0.0); - let mshot = take_model_for(&deal.kind); - let mut take = if deal.is_long() { - fill.price * (1.0 + pct / 100.0) - } else if mshot { - // The core divides a short MoonShot's take off the fill (the core developer, - // 2026-09-23): `fill / (1 + SellPrice/100)`. The two archived short takes that - // SellPrice placed and whose price step tells the formulas apart (ONE, BCH_RP) sit - // on it; MoonHook's stored take is rounded too coarsely to tell, and keeps the - // product. - fill.price / (1.0 + pct / 100.0) - } else { - fill.price * (1.0 - pct / 100.0) - }; - if self.params.sell_at_last_price { - let pre = deal - .pre_spike_ask - .filter(|p| p.is_finite() && *p > 0.0) - .or_else(|| { - pre_spike_price(ticks, fill.t_ms, self.params.model.pre_spike_lookback_ms) - }); - if let (Some(pre), Some(factor)) = (pre, ask_take_factor(self.params, deal.is_short)) { - take = if deal.is_long() { - take.max(pre * factor) - } else { - take.min(pre * factor) - }; - } - } - take - } - - /// The take distance before the modifiers, per cent of the fill: `HookSellLevel` of the - /// trade's own detect depth for a MoonHook, `SellPrice` for every other kind. - /// - /// A hook whose depth or level is unknown falls back to `SellPrice` so the line still has - /// somewhere to step down from — and [`Self::take_known`] answers `false` for it, which is - /// what keeps that fallback out of the verdict. - fn base_take_pct(&self, deal: &Deal) -> f64 { - if deal.kind == KIND_MOONHOOK && self.params.hook_sell_level_pct > 0.0 { - if let Some(depth) = deal.hook_depth_pct.filter(|d| d.is_finite() && *d > 0.0) { - return hook_take_pct(depth, self.params.hook_sell_level_pct); - } - } - self.params.sell_price_pct - } - - /// What the delta modifiers add to the sell level, per cent — the capped sum times - /// `SellModifier`, per the FAQ — as the deltas stood when the sell was placed, at `at_ms`. - fn modifier_pct(&self, deal: &Deal, at_ms: i64) -> f64 { - modifier_sum(self.params, deal, at_ms) * self.params.sell_modifier - } - - /// Whether a walk under these parameters knows where the trade's take stands — the level - /// every PriceDown step and every fill of the line is counted from. - /// - /// Asked with a VARIANT's parameters, so it answers for the variants: a trade whose take a - /// variant cannot place is one the search cannot run, whatever the fact's own replay did - /// with the level the core recorded. Per kind: - /// - /// - a MoonHook needs its rule's inputs — the detect depth and `HookSellLevel`, with - /// `HookSellFixed` off (that branch computes the distance differently and is not modelled: - /// no live strategy sets it, so it could not be checked against anything); - /// - a Spread needs the take the core recorded ([`take_is_recorded`]); - /// - a MoonShot lifted to the pre-spike ask (`MShotSellAtLastPrice`) needs that ask off the - /// core's record (`Deal::pre_spike_ask`) — the tape's print before the spike sits 0.1–0.5 % - /// under the book's ask on a dump, and on 88 stopped MoonShot trades (2026-09-23) a take - /// placed off it was touched before the core's stop on 30, turning a loss into a win; - /// - every other kind takes `SellPrice`, known by construction. - /// - /// `false` is not "the model was wrong": it is "this trade's take is not modelled here", and - /// [`super::verify`] then answers the exit group with nothing — which keeps the trade out of - /// the search (`record::fit_for_search`). - pub fn take_known(&self, deal: &Deal) -> bool { - let positive = |v: Option| v.is_some_and(|x| x.is_finite() && x > 0.0); - if deal.kind == KIND_MOONHOOK { - return !self.params.hook_sell_fixed - && self.params.hook_sell_level_pct > 0.0 - && positive(deal.hook_depth_pct); - } - if take_is_recorded(&deal.kind) { - return positive(deal.archived_take); - } - if take_model_for(&deal.kind) && self.params.sell_at_last_price { - return positive(deal.pre_spike_ask); - } - true - } - /// Replay the tape after the fill: the take, the moving line, the stop. /// /// Args: @@ -353,145 +322,5 @@ impl<'a> ExitModel<'a> { } } -/// The summed delta modifiers of a trade, capped: `Min(MaxModifier, Σ Pn · Dn)`. -/// -/// One sum, two consumers — the sell level through `SellModifier` and the stop through -/// `StopLossModifier` — because the core computes it once and spends it on both (FAQ). -/// -/// The core sums the deltas as they stand when it places the sell: on 121 of its printed sums -/// (2026-09-22) the report's snapshot, stamped at the entry order's placement for every kind but -/// MoonShot, drifted from the core's number the more, the longer the entry order waited. So the -/// sum is read at `at_ms` through the deal's live coin deltas ([`Deal::deltas_at`]); the BTC, -/// market, mark and price-bug terms stay the snapshot, and on the stop the verdict absorbs their -/// residual in its level tolerance (`verify::STOP_PRICE_TOLERANCE`). -/// -/// Args: -/// params: The sell parameters, for the coefficients and the ceiling. -/// deal: The trade, for its deltas. -/// at_ms: When the sell was placed — the fill. -pub fn modifier_sum(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { - let sum = params.sell_mods.near_addition(&deal.deltas_at(at_ms)); - if params.max_modifier > 0.0 { - sum.min(params.max_modifier) - } else { - sum - } -} - -/// The stop distance of a trade, per cent: `StopLoss` adjusted by `StopLossModifier · Σ`. -/// -/// Normally that deepens the stop (a positive coefficient over a positive delta sum), but -/// neither sign is guaranteed: live strategies carry `StopLossModifier` down to −0.3, and a -/// delta sum can be negative, so the adjustment can also pull the stop TOWARD the entry. -/// -/// An adjustment big enough to pull it THROUGH the entry answers `0.0` — no stop on this trade -/// — rather than a level. Clamping it to a hair's breadth from the entry instead would fire on -/// the first print that moves, which is not a stop but a coin flip dressed as one; and placing -/// it beyond the entry would fire on the first print, full stop. What the core does with an -/// adjustment that large is unknown: none of the 1 735 replayed trades reaches this branch, so -/// the model declines to invent an answer. A configured stop on the profit side (`StopLoss` positive — -/// live data has it) is a different thing and is left exactly as configured. -/// -/// Args: -/// params: The sell parameters. -/// deal: The trade, for its deltas. -/// at_ms: When the sell was placed — the fill; see [`modifier_sum`]. -pub fn stop_pct(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { - if params.stop_loss_pct == 0.0 || params.stop_loss_modifier == 0.0 { - return params.stop_loss_pct; - } - let adjusted = - params.stop_loss_pct - modifier_sum(params, deal, at_ms) * params.stop_loss_modifier; - // Same side as configured, or nothing at all. - if adjusted == 0.0 || adjusted.is_sign_negative() != params.stop_loss_pct.is_sign_negative() { - return 0.0; - } - adjusted -} - -/// The kind name of Spread as the strategy list spells it. -pub const KIND_SPREAD: &str = "Spread"; - -/// Whether the kind's take is not a rule of its parameters but a level the core took off the -/// detect — the spread it detected. `SellPrice` places it on 5 of 157 archived Spread takes -/// (2026-09-23) — the coincidences it takes for the width of a spread to land on the field. The -/// report's `comment` keeps only the width, rounded to 0.1 %, so the level is the take the order -/// archive recorded or nothing, for the fact and for every variant alike; and `SellPrice` is no -/// knob of the kind (`params::TICK_PARAMS`). -pub fn take_is_recorded(kind: &str) -> bool { - kind == KIND_SPREAD -} - -/// Whether the take the model computes for the kind is the kind's OWN rule, rather than the -/// general `SellPrice` — true for MoonShot, whose `MShotSellAtLastPrice` lift belongs to it -/// alone. For the rest the verdict prefers the archived level when the trade has one -/// (`Deal::archived_take`, `ExitParams::take_from_archive`), because the core's placed level -/// carries the delta modifiers exactly as the core applied them. -/// -/// It is NOT the test for "is the take known at all" — that is [`ExitModel::take_known`], and -/// reading this one in its place silenced five legitimate verdicts on the live sample -/// (2026-09-22), four of them hits: `SellPrice` is the take of every kind but MoonHook, and a -/// Spread trade without an archived line is judged by it perfectly well. -pub fn take_model_for(kind: &str) -> bool { - super::entry::entry_model_for(kind) -} - -/// The take as an archived Exit line records it: its first point, when it is a price. -pub fn archived_take(exit_points: Option<&[(i64, f64)]>) -> Option { - let (_, take) = exit_points?.first().copied()?; - (take.is_finite() && take > 0.0).then_some(take) -} - -/// What `MShotSellAtLastPrice` multiplies the ask by to place the take: `1 − adjust/100` for a -/// long, `1/(1 − adjust/100)` for a short, whose take sits below the entry and is adjusted UP -/// toward it (the core developer, 2026-09-23: `max(Y·(1 − adj/100), …)` and -/// `min(Y/(1 − adj/100), …)`). `None` for an adjustment of 100 % or more, which leaves no price. -pub fn ask_take_factor(params: &ExitParams, is_short: bool) -> Option { - let keep = 1.0 - params.sell_price_adjust_pct / 100.0; - // NaN included: an adjustment the strategy did not spell as a number leaves no price. - if keep.is_nan() || keep <= 0.0 { - return None; - } - Some(if is_short { 1.0 / keep } else { keep }) -} - -/// The pre-spike ask behind an archived Exit line: its first point is the take as the core -/// placed it, the ask times [`ask_take_factor`] when `MShotSellAtLastPrice` placed it, so the -/// ask is that point with the factor divided out. The ask's branch carries no delta modifier -/// (the core developer, 2026-09-23), so the ask read back is the core's own, to the price step. -/// `None` when the rule was off (the take came from `SellPrice`, and the archive says nothing -/// about the ask), when the archive holds no Exit line, or when the first point is not a price. -/// -/// When `SellPrice` alone set the take farther than the ask would have, the division reads a -/// slightly high ask back — and the same `max` (a long) or `min` (a short, whose take sits -/// below the entry) puts the take on `SellPrice` again, so the trade's own replay is exact -/// either way; a variant with a smaller adjustment inherits the overread. On the live sample -/// (2026-09-23) the ask placed 776 of 785 archived MoonShot takes. -/// -/// Args: -/// exit_points: The archived Exit line's `(t_ms, price)` points, in the archive's order. -/// params: The sell-line parameters as of the trade. -/// is_short: The trade's side — which way the adjustment went. -pub fn archived_pre_spike_ask( - exit_points: Option<&[(i64, f64)]>, - params: &ExitParams, - is_short: bool, -) -> Option { - if !params.sell_at_last_price { - return None; - } - let factor = ask_take_factor(params, is_short)?; - let (_, take) = exit_points?.first().copied()?; - (take.is_finite() && take > 0.0).then_some(take / factor) -} - -/// The last print at least `lookback_ms` ([`super::mshot::PRE_SPIKE_LOOKBACK_MS`] by default) -/// before `at_ms` — the FAQ's "price before the spike". -pub fn pre_spike_price(ticks: &[Tick], at_ms: i64, lookback_ms: i64) -> Option { - let cutoff = at_ms - lookback_ms; - ticks - .iter() - .rev() - .find(|t| (t.time_ms as i64) <= cutoff && t.price > 0.0) - .map(|t| f64::from(t.price)) -} +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs new file mode 100644 index 00000000..b44c4452 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs @@ -0,0 +1,39 @@ +//! The strategy window's "Delta Modifiers" section: the `Add*Delta` family, `SellModifier` and +//! `MaxModifier` — one capped sum of the trade's deltas, spent on the take through +//! `SellModifier` and on the stop through `StopLossModifier` ([`super::stops::stop_pct`]). + +use super::{ExitModel, ExitParams}; +use crate::db::tuner::ticks::Deal; + +impl ExitModel<'_> { + /// What the delta modifiers add to the sell level, per cent — the capped sum times + /// `SellModifier`, per the FAQ — as the deltas stood when the sell was placed, at `at_ms`. + pub(super) fn modifier_pct(&self, deal: &Deal, at_ms: i64) -> f64 { + modifier_sum(self.params, deal, at_ms) * self.params.sell_modifier + } +} + +/// The summed delta modifiers of a trade, capped: `Min(MaxModifier, Σ Pn · Dn)`. +/// +/// One sum, two consumers — the sell level through `SellModifier` and the stop through +/// `StopLossModifier` — because the core computes it once and spends it on both (FAQ). +/// +/// The core sums the deltas as they stand when it places the sell: on 121 of its printed sums +/// (2026-09-22) the report's snapshot, stamped at the entry order's placement for every kind but +/// MoonShot, drifted from the core's number the more, the longer the entry order waited. So the +/// sum is read at `at_ms` through the deal's live coin deltas ([`Deal::deltas_at`]); the BTC, +/// market, mark and price-bug terms stay the snapshot, and on the stop the verdict absorbs their +/// residual in its level tolerance (`verify::STOP_PRICE_TOLERANCE`). +/// +/// Args: +/// params: The sell parameters, for the coefficients and the ceiling. +/// deal: The trade, for its deltas. +/// at_ms: When the sell was placed — the fill. +pub fn modifier_sum(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { + let sum = params.sell_mods.near_addition(&deal.deltas_at(at_ms)); + if params.max_modifier > 0.0 { + sum.min(params.max_modifier) + } else { + sum + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/line.rs b/crates/moon-core/src/db/tuner/ticks/exit/line.rs new file mode 100644 index 00000000..d279c1c3 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/line.rs @@ -0,0 +1,295 @@ +//! The moving sell line: where the sell order stood at every moment after the fill, and which +//! print crossed it — the step every section's rules share. The rules themselves live with their +//! section of the strategy window: [`super::stops`], [`super::sell_order`], [`super::sell_shot`], +//! [`super::delta_mods`], and PumpsDetection's own [`super::pump_move`]. +//! +//! Every rule is written for a long and mirrored for a short by `Side`. A replacement reaches +//! the exchange `latency_ms` later, as the entry's does: a spike through the OLD level in that +//! gap fills there. The line's replacements are recorded so the model can be held against the +//! archived Exit line of the trade. +//! +//! What goes to the exchange is rounded to the nearest step of the market's price grid (a sell +//! limit is placed on the grid), and the rules carry on from the ORDER's price once a move +//! reached the book — from the computed value while rounding kept the order where it was +//! ([`advance`]). The rounding is what decides a print AT the level: on ARX (2026-09-21) the +//! `PriceDownAllowedDrop` floor computed to 0.196445, the core's order stood at 0.1964, and +//! the tape's high was exactly 0.1964 — the unrounded line was never reached. + +use super::pump_move::PumpMove; +use super::sell_order::{PriceDown, SellLevel, armed_at}; +use super::sell_shot::SellShot; +use super::stops::Stops; +use super::{ExitParams, Side}; +use crate::db::tuner::ticks::{Deal, Exit, ExitKind, Fill, reaches, round_to_step}; +use crate::feed::types::Tick; + +/// One replacement of the line, for the comparison with the archived Exit points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LinePoint { + pub t_ms: i64, + pub price: f64, +} + +/// The walk's answer: the exit and every level the line stood at, placement first. +#[derive(Clone, Debug, PartialEq)] +pub struct LineWalk { + pub exit: Exit, + pub points: Vec, +} + +/// One step of the core's sell price: `level` is what a rule computed, `order` that level on +/// the price grid. When the order lands on a new price, the move goes to the book and the core +/// carries on from the ORDER's price; when rounding keeps it where it was, nothing is sent and +/// the core carries on from the computed value, so the next step can still cross a grid line. +/// Answers whether the order moved. +/// +/// Read off the archived Exit lines (2026-09-22): every step of INDEX's twelve (Gate, relative +/// PriceDown 10 %) lands only when chained off the placed price — off the exact value the second +/// already rounds a step short — and FATCOIN's one-step-per-tick lines climb past a step that +/// rounds back onto the order only when that step's exact value carries into the next. Over +/// 1 421 archived PriceDown lines the rule reproduces every level of 997, against 647 for the +/// exact chain and 949 for the placed price alone. +/// +/// Args: +/// core: The core's sell price, advanced in place. +/// last_sent: The order's price as last sent, advanced when the order moves. +/// level: The level a rule computed. +/// order: `level` on the price grid. +fn advance(core: &mut f64, last_sent: &mut f64, level: f64, order: f64) -> bool { + if (order - *last_sent).abs() <= f64::EPSILON * last_sent.abs() { + *core = level; + return false; + } + *core = order; + *last_sent = order; + true +} + +/// What the exchange is given: the level on the price grid of `tick`, the exact level when the +/// grid is unknown. +fn placed(tick: Option, level: f64) -> f64 { + match tick { + Some(tick) => round_to_step(level, tick), + None => level, + } +} + +/// The sell order as the rules move it: the core's price, what the exchange holds, the move on +/// its way there, and every level it was sent to. +pub(super) struct Line { + /// The market's price step, `None` when the market's grid is unknown. + tick: Option, + latency_ms: i64, + /// When the take was placed (`SellDelay` after the fill). + armed_at: i64, + /// When the take is on the book: placed at `armed_at`, there after the same latency as any + /// move of the line. + live_at: i64, + /// The exchange's level (what fills, on the grid). + exch: f64, + /// Whether a move has reached the exchange: what tells a fill at the take from a fill at + /// a level a rule moved the line to — not the price, which a moved line can round back onto. + exch_moved: bool, + /// The core's sell price: the ORDER's price once a move reached the book, the computed value + /// while rounding kept the order where it was — see [`advance`]. The take is an order too. + core: f64, + /// The last level sent to the book, pending or not: what a new level must differ from to + /// be a move at all. + last_sent: f64, + /// A move the exchange has not seen yet: when it lands, and at what level. + pending: Option<(i64, f64)>, + points: Vec, +} + +impl Line { + /// The take, placed at `armed_at` on the grid of `tick`. + fn new(tick: Option, take: f64, armed_at: i64, latency_ms: i64) -> Self { + let take_placed = placed(tick, take); + Self { + tick, + latency_ms, + armed_at, + live_at: armed_at + latency_ms, + exch: take_placed, + exch_moved: false, + core: take_placed, + last_sent: take_placed, + pending: None, + points: vec![LinePoint { + t_ms: armed_at, + price: take_placed, + }], + } + } + + /// The core's sell price, which every rule moves from. + pub(super) fn core(&self) -> f64 { + self.core + } + + /// Send the line to `level` at `t_ms`. Answers whether the order moved — a replace went to + /// the book. + pub(super) fn place(&mut self, t_ms: i64, level: f64) -> bool { + if (level - self.core).abs() <= f64::EPSILON * self.core.abs() { + return false; + } + let order = placed(self.tick, level); + if !advance(&mut self.core, &mut self.last_sent, level, order) { + return false; + } + self.pending = Some((t_ms + self.latency_ms, order)); + self.points.push(LinePoint { + t_ms: t_ms + self.latency_ms, + price: order, + }); + true + } + + /// The move on its way lands on the exchange, when it is due by `t_ms`. + fn land(&mut self, t_ms: i64) { + if let Some((_, level)) = self.pending.filter(|(apply_at, _)| t_ms >= *apply_at) { + self.exch = level; + self.exch_moved = true; + self.pending = None; + } + } + + /// The print at `t_ms`, `price` filling the order the exchange holds: once the take is on + /// the book, a print AT the level or through it. + fn fill_on(&self, t_ms: i64, price: f64, side: Side) -> Option { + (t_ms > self.armed_at && t_ms >= self.live_at && reaches(price, self.exch, !side.long)) + .then_some(Exit { + t_ms, + price: self.exch, + // What the print met: the take as placed, or a level a rule moved it to. + kind: if !self.exch_moved { + ExitKind::Take + } else { + ExitKind::Line + }, + }) + } + + fn close(self, exit: Exit) -> LineWalk { + LineWalk { + exit, + points: self.points, + } + } +} + +/// Walk the tape after the fill under `params`, starting from the take `take`. +/// +/// Args: +/// deal: The row — its side. +/// ticks: The window's prints, ascending. +/// fill: The entry. +/// take: The take level the line starts at (see `ExitModel::take_level`). +/// params: The sell-line rules. +pub fn walk(deal: &Deal, ticks: &[Tick], fill: Fill, take: f64, params: &ExitParams) -> LineWalk { + walk_held(deal, ticks, fill, take, params, None) +} + +/// [`walk`] with the sell HELD — no print fills it — until `hold_until_ms`: the rules keep +/// moving the line, so its level at that moment is known whatever print the model would have +/// sold on before it. The stop is not held: it is a market order and fires as it does. The +/// verdict on a fact reads the line this way, at the close. +/// +/// Args: +/// hold_until_ms: `None` walks as [`walk`] does. +pub fn walk_held( + deal: &Deal, + ticks: &[Tick], + fill: Fill, + take: f64, + params: &ExitParams, + hold_until_ms: Option, +) -> LineWalk { + let side = Side { + long: deal.is_long(), + }; + let armed_at = armed_at(fill, params); + let mut line = Line::new(deal.tick, take, armed_at, params.model.latency_whole_ms()); + let mut price_down = PriceDown::new(params, deal, fill, side); + let mut pump_move = PumpMove::new(params, fill, side, armed_at); + let mut sell_level = SellLevel::new(params, fill, side); + let mut sell_shot = SellShot::new(params, fill, side); + let mut stops = Stops::new(deal, ticks, fill, params, side); + + let mut last_t = fill.t_ms; + for (index, tick) in ticks.iter().enumerate() { + let t_ms = tick.time_ms as i64; + let price = f64::from(tick.price); + if t_ms <= fill.t_ms || !price.is_finite() || price <= 0.0 { + continue; + } + last_t = t_ms; + let seen = &ticks[..=index]; + // The fact's own stop fired between the last print and this one: ahead of anything + // this print does, and after everything the prints before it did. + if let Some(exit) = stops.fired_by(t_ms) { + return line.close(exit); + } + // The timer-driven rules moved the line at their own moments, between prints; every + // step due by this print happened BEFORE it, and a step that also reached the book + // before it is what this print meets. + // + // PriceDown steps, one per due moment, and the pump move, in the order they fell due — + // the pump move first on a tie: each step chains off where the one before it left the + // line. + loop { + let pd_due = price_down.due(t_ms); + let pm_due = pump_move.due(t_ms); + if let Some(due) = pm_due.filter(|pm| pd_due.is_none_or(|pd| *pm <= pd)) { + pump_move.step(due, seen, &mut line); + continue; + } + let Some(due) = pd_due else { + break; + }; + price_down.step(due, &mut line); + } + // SellLevel is not in that race: its moves due by this print all come after it, as a + // pass of their own. + sell_level.catch_up(t_ms, seen, &mut line); + line.land(t_ms); + // The stops come before the print-driven rule below moves anything. + if let Some(exit) = stops.on_print(tick, t_ms, price) { + return line.close(exit); + } + // A print AT the level fills the sell — the optimistic reading the spec states (§7: + // the queue standing at the level is not modelled; COOL 2026-09-21 printed 31 + // contracts at the level against a sell of 18 000 and the core's line stood). The + // verdict on the fact does not lean on this: `verify` judges the line by where it + // STOOD at the close, not by which print the model sold on. + // + // The take is an order like every move, and reaches the book `latency_ms` after the + // core placed it: the spike's own tail, printed in the milliseconds after the fill, is + // not a print the take was there for. Filling on it turned 30 of 88 stopped MoonShot + // trades into wins on the live sample (2026-09-23), the take "touched" 9 ms after the + // buy by the pump it was bought on. + let held = hold_until_ms.is_some_and(|until| t_ms <= until); + if !held { + if let Some(exit) = line.fill_on(t_ms, price, side) { + return line.close(exit); + } + } + // SellShot: the line follows the market inside its corridor — driven by this print. + sell_shot.on_print(t_ms, seen, &mut line); + } + let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(last_t); + // The fact's own stop past the last print, or the book stop's samples up to the tape's end. + if let Some(exit) = stops.after_tape(tail) { + return line.close(exit); + } + // Nothing closed it inside the tape. Not the report's own exit: a variant that never + // closes is not a trade, whatever the core's rules did, and the caption counts it. + line.close(Exit { + t_ms: tail, + price: f64::NAN, + kind: ExitKind::OpenAtWindowEnd, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/line/tests.rs new file mode 100644 index 00000000..f64801ad --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/line/tests.rs @@ -0,0 +1,350 @@ +//! The sell line's shared step on synthetic tapes: the price grid, the latency, the mirror, +//! the chain across a step, and the archive's clock. + +use super::*; +use crate::db::tuner::ticks::exit::ExitModel; +use crate::db::tuner::ticks::exit::tests::{deal, fill, params, tape}; +use crate::db::tuner::ticks::{EntryParams, ModelSettings, verify}; + +#[test] +fn the_placed_level_is_rounded_to_the_step() { + // ARX, 2026-09-21, step 0.0001: take 0.197802 goes to the book as 0.1978; the 20 % + // relative steps chain off the placed prices (0.1978 → 0.19714 → 0.1971 → 0.19658), and + // the floor at +1 % (0.196445) is placed at 0.1964 — which is why the print AT 0.1964 + // sells. + let p = ExitParams { + price_down_timer_s: 3.0, + price_down_pct: 20.0, + price_down_delay_s: 10.0, + price_down_relative: true, + price_down_allowed_drop_pct: 1.0, + ..params() + }; + let mut d = deal(false); + d.buy_price = 0.1945; + d.tick = Some(0.0001); + let fill = Fill { + t_ms: 0, + price: 0.1945, + }; + let ticks = tape(&[ + (1_000, 0.1950), + (4_000, 0.1950), + (14_000, 0.1950), + (24_000, 0.1950), + (34_000, 0.1950), + (40_000, 0.1964), + ]); + let w = walk(&d, &ticks, fill, 0.197802, &p); + let levels: Vec = w.points.iter().map(|pt| pt.price).collect(); + let on_grid = |v: f64| (v / 0.0001).round() * 0.0001; + assert_eq!(levels.len(), 4, "{levels:?}"); + for (level, want) in levels.iter().zip([0.1978, 0.1971, 0.1966, 0.1964]) { + assert!((level - want).abs() < 1e-9, "{levels:?}"); + assert!( + (level - on_grid(*level)).abs() < 1e-9, + "off the grid: {level}" + ); + } + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 40_000)); + assert!( + (w.exit.price - 0.1964).abs() < 1e-9, + "sold at the placed level" + ); +} + +#[test] +fn without_a_step_the_placed_level_is_the_exact_one() { + let p = ExitParams { + price_down_timer_s: 3.0, + price_down_pct: 20.0, + price_down_delay_s: 10.0, + price_down_relative: true, + price_down_allowed_drop_pct: 1.0, + ..params() + }; + let mut d = deal(false); + d.buy_price = 0.1945; + let fill = Fill { + t_ms: 0, + price: 0.1945, + }; + let ticks = tape(&[(1_000, 0.1950), (40_000, 0.1964)]); + let w = walk(&d, &ticks, fill, 0.197802, &p); + assert_eq!( + w.exit.kind, + ExitKind::OpenAtWindowEnd, + "0.196445 is above the tape's high" + ); +} + +/// The take is an order like every move: on the book `latency_ms` after the core placed it. The +/// spike's own tail, printed in the milliseconds after the fill, cannot fill it. +#[test] +fn the_take_is_on_the_book_only_after_the_latency() { + let p = ExitParams { + model: ModelSettings { + latency_ms: 100.0, + ..ModelSettings::default() + }, + ..ExitParams::default() + }; + let ticks = tape(&[(9, 101.5), (150, 101.2)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Take, 150)); +} + +// ---- the mirror and the archive ------------------------------------------------------------- + +#[test] +fn a_short_mirrors_every_rule() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_allowed_drop_pct: 0.1, + stop_loss_pct: -1.0, + ..params() + }; + // Take 99 (1 % below the buy at 100); one step -> 99.5 at t=1000; a print down to 99.4 + // before the next step crosses it. + let ticks = tape(&[(1_500, 100.0), (1_900, 99.4)]); + let w = walk(&deal(true), &ticks, fill(), 99.0, &p); + assert!((w.points[1].price - 99.5).abs() < 1e-9, "{:?}", w.points); + assert_eq!(w.exit.kind, ExitKind::Line); + assert!((w.exit.price - 99.5).abs() < 1e-9); + // The stop is ABOVE a short's buy. + let ticks = tape(&[(500, 101.2)]); + let w = walk(&deal(true), &ticks, fill(), 99.0, &p); + assert_eq!(w.exit.kind, ExitKind::Stop); +} + +#[test] +fn a_spike_through_the_old_level_fills_before_a_step_lands() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + model: ModelSettings { + latency_ms: 100.0, + ..ModelSettings::default() + }, + ..params() + }; + // The step is decided at t=1000 and reaches the book at t=1100; a print at 101 at t=1050 + // fills the old take at 101, not the new line at 100.5. + let ticks = tape(&[(1_000, 100.0), (1_050, 101.0)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert_eq!(w.exit.kind, ExitKind::Take); + assert!((w.exit.price - 101.0).abs() < 1e-9); +} + +#[test] +fn verify_holds_the_line_against_the_archived_points() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_allowed_drop_pct: 0.1, + ..params() + }; + let mut d = deal(false); + d.sell_price = 100.25; + let ticks = tape(&[(0, 100.0), (1_500, 100.0), (2_500, 100.3)]); + // The archive's points are what the core did: placed at 101, 100.5 at 1 s, 100.25 at 2 s. + let archived = [(0, 101.0), (1_000, 100.5), (2_000, 100.25)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&archived)); + assert_eq!(v.exit_kind, Some(ExitKind::Line)); + assert_eq!(v.exit, Some(true), "{v:?}"); + assert_eq!(v.line_points, Some((3, 3))); + // A line the core moved differently - a point the model never re-placed at - is a miss + // even when the exit price agrees. + let other = [(0, 101.0), (1_000, 100.7), (2_000, 100.25)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&other)); + assert_eq!(v.exit, Some(false)); + assert_eq!(v.line_points, Some((2, 3))); + // The same walk through `ExitModel`. + let w = ExitModel::new(&p).walk(&d, &ticks, fill()); + assert_eq!(w.points.len(), 3); +} + +// ---- the core's sell price across a step -------------------------------------------------------- + +/// Relative PriceDown on the grid, flat tape under the line: `(t, level)` of every point. +fn grid_walk(take: f64, step_lag_ms: f64, ticks: &[Tick]) -> Vec<(i64, f64)> { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 10.0, + price_down_delay_s: 1.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.1, + ..params() + }; + let mut d = deal(false); + d.buy_price = 1_000.0; + d.tick = Some(1.0); + d.step_lag_ms = step_lag_ms; + let fill = Fill { + t_ms: 0, + price: 1_000.0, + }; + walk(&d, ticks, fill, take, &p) + .points + .iter() + .map(|pt| (pt.t_ms, pt.price)) + .collect() +} + +/// A step that moved the order chains the next one off the ORDER's price: 1096 → 1086.4, placed +/// 1086; then 1086 → 1077.4, placed 1077 — off the exact 1086.4 it would round to 1078. INDEX +/// 2026-09-22 (Gate, 10 % relative): all twelve archived levels land only this way. +#[test] +fn a_moved_order_chains_off_its_placed_price() { + let ticks = tape(&[(500, 1_000.0), (1_500, 1_000.0), (2_500, 1_000.0)]); + assert_eq!( + grid_walk(1_096.0, 0.0, &ticks), + vec![(0, 1_096.0), (1_000, 1_086.0), (2_000, 1_077.0)] + ); +} + +/// A step that rounds back onto the order sends nothing and carries its exact value into the +/// next: 1004 → 1003.6 (still 1004), → 1003.24 (1003), → 1002.7 (still 1003), → 1002.43 (1002). +/// Off the placed price alone the line would never leave 1004; off the exact chain the second +/// move would come a step late. FATCOIN 2026-09-22 climbs one tick every other step this way. +#[test] +fn a_step_rounding_back_onto_the_order_carries_its_exact_value() { + let ticks = tape(&[ + (500, 1_000.0), + (1_500, 1_000.0), + (2_500, 1_000.0), + (3_500, 1_000.0), + (4_500, 1_000.0), + ]); + assert_eq!( + grid_walk(1_004.0, 0.0, &ticks), + vec![(0, 1_004.0), (2_000, 1_003.0), (4_000, 1_002.0)] + ); +} + +/// The core's own replace lag spaces the steps: the first is timed off the take by +/// `PriceDownTimer`, the next one after a step that moved the order off that step plus the delay +/// plus the lag — here every step moves the order. +#[test] +fn the_core_step_lag_spaces_the_price_down_steps() { + let ticks = tape(&[ + (500, 1_000.0), + (1_500, 1_000.0), + (2_500, 1_000.0), + (3_500, 1_000.0), + ]); + let times: Vec = grid_walk(1_096.0, 50.0, &ticks) + .iter() + .map(|&(t, _)| t) + .collect(); + assert_eq!(times, vec![0, 1_000, 2_050, 3_100]); +} + +/// A step rounding kept in place sent nothing, so the next one waits for no round trip: the +/// lag follows only the steps that moved the order (1004 stays at 1 s, 1003 at 2 s, 1003 stays +/// at 3.05 s, 1002 at 4.05 s). +#[test] +fn a_step_kept_in_place_adds_no_lag() { + let ticks = tape(&[ + (500, 1_000.0), + (1_500, 1_000.0), + (2_500, 1_000.0), + (3_500, 1_000.0), + (4_500, 1_000.0), + ]); + assert_eq!( + grid_walk(1_004.0, 50.0, &ticks), + vec![(0, 1_004.0), (2_000, 1_003.0), (4_050, 1_002.0)] + ); +} + +// ---- the verdict's clock --------------------------------------------------------------------- + +/// PriceDown 50 % relative every second, 100 ms to the book: the take and its steps. +fn clock_params(take_pct: f64) -> ExitParams { + ExitParams { + sell_price_pct: take_pct, + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_allowed_drop_pct: 0.1, + model: ModelSettings { + latency_ms: 100.0, + ..ModelSettings::default() + }, + ..params() + } +} + +/// The core stepped onto 100.5 at 900 ms and sold there 100 ms before the close — the archive +/// files the step and the fill as one point. The model's step to 100.5 is stamped 1 100 ms: late +/// by 200 ms, inside the point tolerance. On the model's own clock the fill found the take still +/// standing and the verdict had nothing to say; on the archive's the line stood at 100.5. +#[test] +fn the_level_at_the_fill_is_read_on_the_archive_clock() { + let mut d = deal(false); + d.sell_price = 100.5; + d.close_ms = 1_000; + let ticks = tape(&[(0, 100.0), (500, 100.0), (1_500, 100.0)]); + let archived = [(0, 101.0), (900, 100.5)]; + let v = verify( + &d, + &ticks, + &EntryParams::Fact, + &clock_params(1.0), + None, + Some(&archived), + ); + assert_eq!(v.exit_kind, Some(ExitKind::Line), "{v:?}"); + assert_eq!(v.exit, Some(true), "{v:?}"); +} + +/// A step the model took with no archived move to match: well before the fill it is a step the +/// core never took, and its level is the answer; inside the point tolerance of the fill it is +/// the model's timing, and the core's last level is. +#[test] +fn a_stray_step_counts_only_outside_the_point_tolerance_of_the_fill() { + // Take 102; the model steps to 101 (1 100 ms), 100.5 (2 100 ms), 100.25 (3 100 ms). The + // core stepped once, to 101, and filled a touch above it. + let mut d = deal(false); + d.sell_price = 101.02; + let ticks = tape(&[ + (0, 100.0), + (500, 100.0), + (1_500, 100.0), + (2_500, 100.0), + (3_500, 100.0), + ]); + let p = clock_params(2.0); + // Filled at 3 450 ms: the model's 100.5 at 2 100 ms is more than a second before it. + d.close_ms = 3_600; + let late = [(0, 102.0), (1_000, 101.0), (3_450, 101.02)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&late)); + assert_eq!(v.exit, Some(false), "{v:?}"); + // Filled at 2 500 ms: the model's 100.5 came 400 ms before it. + d.close_ms = 2_600; + let soon = [(0, 102.0), (1_000, 101.0), (2_500, 101.02)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&soon)); + assert_eq!(v.exit, Some(true), "{v:?}"); +} + +/// The fact's sell timers run from the take the core placed — the archive's first point, less +/// `SellDelay` — when that is later than the report's buy stamp. +#[test] +fn the_fact_sell_starts_at_the_archived_take() { + let d = deal(false); + let p = params(); + let start = + |points: Option<&[(i64, f64)]>, p: &ExitParams| verify::fact_sell_start(&d, p, points).t_ms; + assert_eq!(start(Some(&[(2_000, 101.0), (3_000, 100.5)]), &p), 2_000); + let delayed = ExitParams { + sell_delay_ms: 500.0, + ..params() + }; + assert_eq!(start(Some(&[(2_000, 101.0)]), &delayed), 1_500); + assert_eq!(start(Some(&[(-7, 101.0)]), &p), 0, "never before the buy"); + assert_eq!(start(None, &p), 0); +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/pump_move.rs b/crates/moon-core/src/db/tuner/ticks/exit/pump_move.rs new file mode 100644 index 00000000..b6d6bd77 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/pump_move.rs @@ -0,0 +1,75 @@ +//! PumpsDetection's one sell move — a field of that kind's own settings, not of the sell +//! sections the other kinds share. +//! +//! **PumpMove** (`PumpMoveTimer` non-zero) — once, `PumpMoveTimer` seconds after the take is +//! placed, the sell moves to `PumpMovePersent` per cent of the way from the pump's peak back to +//! the buy (FAQ: "учитывается процент между пиковой ценой и ценой покупки"), the peak read over +//! [`PUMP_PEAK_LOOKBACK_MS`] before the take up to the move. `docs-internal/STRATEGY_FORMULAS/ +//! pumpsdetection.md` has the archive it was read off. + +use super::line::Line; +use super::{ExitParams, Side, due_by}; +use crate::db::tuner::ticks::Fill; +use crate::feed::types::Tick; + +/// How far past `PumpMoveTimer` the core's pump move lands, less the model's own placement +/// latency: over 32 archived PumpsDetection lines (2026-09-22, every live Pump strategy runs +/// `PumpMoveTimer` 2 with `PumpMovePersent` 1) the move came 575–704 ms past the timer, a +/// median of 610 ms — counted from the take, not from the buy's report stamp: one take placed +/// 32 s after `buydatems` still moved 2.6 s after itself. +pub const PUMP_MOVE_LAG_MS: i64 = 500; + +/// How far before the take the pump's peak is looked for. The FAQ counts the peak from the +/// detect, which the report does not stamp on older rows, and the peak itself is the print that +/// triggered it — a median 70 ms before the buy, up to 6 s when the buy order waited for the +/// retrace. Over the same 32 lines a window from 10 s before the take reproduces the moved level +/// on 31; from the buy it reproduces 1, from 4 s before the take 27. +pub const PUMP_PEAK_LOOKBACK_MS: i64 = 10_000; + +/// The pump move's clock: one move, timed off the take (see [`PUMP_MOVE_LAG_MS`]). +pub(super) struct PumpMove<'a> { + params: &'a ExitParams, + fill: Fill, + side: Side, + /// When the move is due; `None` when the rule is off or the move went. + next: Option, + /// Where the look-back for the pump's peak starts: [`PUMP_PEAK_LOOKBACK_MS`] (by default) + /// before the take. + peak_from: i64, +} + +impl<'a> PumpMove<'a> { + pub(super) fn new(params: &'a ExitParams, fill: Fill, side: Side, armed_at: i64) -> Self { + Self { + params, + fill, + side, + next: (params.pump_move_timer_s > 0.0).then(|| { + armed_at + + (params.pump_move_timer_s * 1000.0) as i64 + + params.model.pump_move_lag_ms + }), + peak_from: armed_at - params.model.pump_peak_lookback_ms, + } + } + + /// The move due by the print at `t_ms`, if any. + pub(super) fn due(&self, t_ms: i64) -> Option { + due_by(self.next, t_ms) + } + + /// The move due at `due`: to the peak less `PumpMovePersent` of its distance to the buy. + /// + /// Args: + /// seen: The prints up to and including the one the move is due by. + pub(super) fn step(&mut self, due: i64, seen: &[Tick], line: &mut Line) { + self.next = None; + if let Some(peak) = self.side.extreme_between(seen, self.peak_from, due) { + let next = peak + (self.fill.price - peak) * self.params.pump_move_pct / 100.0; + line.place(due, next); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/pump_move/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/pump_move/tests.rs new file mode 100644 index 00000000..a98e18fd --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/pump_move/tests.rs @@ -0,0 +1,34 @@ +//! PumpsDetection's pump move on a synthetic tape. + +use super::*; +use crate::db::tuner::ticks::exit::line::walk; +use crate::db::tuner::ticks::exit::tests::{deal, fill, params, tape}; + +// ---- PumpMove ------------------------------------------------------------------------------ + +/// `PumpMoveTimer` after the take, once: to `PumpMovePersent` of the peak-to-buy distance short +/// of the pump's peak — which printed before the buy. Peak 110, buy 100, 1 %: 109.9. +#[test] +fn the_pump_move_goes_once_to_the_peak_less_its_share() { + let p = ExitParams { + pump_move_timer_s: 2.0, + pump_move_pct: 1.0, + ..params() + }; + let mut d = deal(false); + d.kind = "PumpsDetection".into(); + d.tick = Some(0.01); + let ticks = tape(&[ + (-3_000, 104.0), + (-50, 110.0), + (500, 101.0), + (1_500, 102.0), + (2_600, 101.5), + (6_000, 101.0), + ]); + let w = walk(&d, &ticks, fill(), 104.0, &p); + let points: Vec<(i64, f64)> = w.points.iter().map(|pt| (pt.t_ms, pt.price)).collect(); + assert_eq!(points.len(), 2, "{points:?}"); + assert_eq!(points[1].0, 2_000 + PUMP_MOVE_LAG_MS); + assert!((points[1].1 - 109.9).abs() < 1e-9, "{points:?}"); +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs new file mode 100644 index 00000000..ec7604e0 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs @@ -0,0 +1,383 @@ +//! The strategy window's "Sell order" section: the take, `SellDelay`, `PriceDown*` and +//! `SellLevel*`. +//! +//! - **The take** is `SellPrice` per cent above the fill for every kind but two: MoonHook, whose +//! take replaces it with `HookSellLevel` per cent of the trade's own detect depth +//! ([`crate::db::tuner::ticks::hook`]), and Spread, whose take is the edge of the spread it +//! detected — a level, not a rule, taken as the core recorded it ([`take_is_recorded`]). The +//! Delta-Modifier family moves it ([`super::delta_mods`]). It is raised by +//! `MShotSellAtLastPrice` to the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the +//! 4-second-old ASK, i.e. before the spike"; the model takes the ask the caller recovered from +//! the order archive (`Deal::pre_spike_ask`), else reads the last print at least +//! `ModelSettings::pre_spike_lookback_ms` ([`crate::db::tuner::ticks::mshot::PRE_SPIKE_LOOKBACK_MS`] +//! by default) before the fill, since the tape has no book). +//! - **SellDelay** — milliseconds after the fill before the take is placed ([`armed_at`]). +//! +//! From the Moonbot FAQ (`PriceDown*`, `SellLevel*` answers) and the live strategies +//! (2026-09-20: 862 of 1 331 run `PriceDownTimer` 1 s with `PriceDownPercent` 50 relative, +//! `SellLevelDelay` is absent everywhere): +//! +//! - **PriceDown** — `PriceDownTimer` seconds after the buy the sell is lowered by +//! `PriceDownPercent`: of the distance to the buy when `PriceDownRelative` (`sell −= (sell − +//! buy) · pct`), of the price otherwise (`sell −= buy · pct`); then again every +//! `PriceDownDelay` seconds (0 reads as the terminal's own 0.33 s floor), never below +//! `PriceDownAllowedDrop` per cent over the buy. A zero timer never starts. +//! - **SellLevel** — `SellLevelDelay` seconds after the buy (a negative value is the 0.33 s +//! floor, zero never), the sell moves to the highest print of the last `SellLevelTime` +//! seconds adjusted by `SellLevelAdjust` per cent — of that high, or of its distance to the +//! buy when `SellLevelRelative` — at most `SellLevelCount` times, every `SellLevelDelayNext` +//! (or `SellLevelDelay`) seconds, inside `SellLevelWorkTime`, never below +//! `SellLevelAllowedDrop` per cent over the buy. + +use super::line::Line; +use super::{ExitModel, ExitParams, Side, due_by}; +use crate::db::tuner::ticks::hook::{KIND_MOONHOOK, hook_take_pct}; +use crate::db::tuner::ticks::{Deal, Fill}; +use crate::feed::types::Tick; + +impl ExitModel<'_> { + /// The take-profit level for a fill: `SellPrice` off the fill, lifted to the pre-spike + /// ask (the archive's, else the tape's last print) less the adjustment when + /// `MShotSellAtLastPrice` is on. Long above, short below. + pub fn take_level(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> f64 { + // A kind whose take rule the model does not have starts where the core's line did — + // when the fact is being judged; a variant computes the take from the rules for every + // kind (see `ExitParams::take_from_archive`) but the one whose take is not a rule at + // all ([`take_is_recorded`]). The recorded level is the core's own, modifiers and all, + // so nothing below is added to it. + if (self.params.take_from_archive && !take_model_for(&deal.kind)) + || take_is_recorded(&deal.kind) + { + if let Some(take) = deal.archived_take.filter(|t| t.is_finite() && *t > 0.0) { + return take; + } + } + // Floored at zero: a modifier deep enough to drive the distance negative would put the + // TAKE on the losing side of the entry and turn every level the line steps down from + // inside out. The rules that legitimately sell below the entry are the moving ones + // (`PriceDownAllowedDrop`, a negative `SellShotDistance`), and they get there by + // stepping down from the take, not by starting underneath it. + let pct = (self.base_take_pct(deal) + self.modifier_pct(deal, fill.t_ms)).max(0.0); + let mshot = take_model_for(&deal.kind); + let mut take = if deal.is_long() { + fill.price * (1.0 + pct / 100.0) + } else if mshot { + // The core divides a short MoonShot's take off the fill (the core developer, + // 2026-09-23): `fill / (1 + SellPrice/100)`. The two archived short takes that + // SellPrice placed and whose price step tells the formulas apart (ONE, BCH_RP) sit + // on it; MoonHook's stored take is rounded too coarsely to tell, and keeps the + // product. + fill.price / (1.0 + pct / 100.0) + } else { + fill.price * (1.0 - pct / 100.0) + }; + if self.params.sell_at_last_price { + let pre = deal + .pre_spike_ask + .filter(|p| p.is_finite() && *p > 0.0) + .or_else(|| { + pre_spike_price(ticks, fill.t_ms, self.params.model.pre_spike_lookback_ms) + }); + if let (Some(pre), Some(factor)) = (pre, ask_take_factor(self.params, deal.is_short)) { + take = if deal.is_long() { + take.max(pre * factor) + } else { + take.min(pre * factor) + }; + } + } + take + } + + /// The take distance before the modifiers, per cent of the fill: `HookSellLevel` of the + /// trade's own detect depth for a MoonHook, `SellPrice` for every other kind. + /// + /// A hook whose depth or level is unknown falls back to `SellPrice` so the line still has + /// somewhere to step down from — and [`Self::take_known`] answers `false` for it, which is + /// what keeps that fallback out of the verdict. + fn base_take_pct(&self, deal: &Deal) -> f64 { + if deal.kind == KIND_MOONHOOK && self.params.hook_sell_level_pct > 0.0 { + if let Some(depth) = deal.hook_depth_pct.filter(|d| d.is_finite() && *d > 0.0) { + return hook_take_pct(depth, self.params.hook_sell_level_pct); + } + } + self.params.sell_price_pct + } + + /// Whether a walk under these parameters knows where the trade's take stands — the level + /// every PriceDown step and every fill of the line is counted from. + /// + /// Asked with a VARIANT's parameters, so it answers for the variants: a trade whose take a + /// variant cannot place is one the search cannot run, whatever the fact's own replay did + /// with the level the core recorded. Per kind: + /// + /// - a MoonHook needs its rule's inputs — the detect depth and `HookSellLevel`, with + /// `HookSellFixed` off (that branch computes the distance differently and is not modelled: + /// no live strategy sets it, so it could not be checked against anything); + /// - a Spread needs the take the core recorded ([`take_is_recorded`]); + /// - a MoonShot lifted to the pre-spike ask (`MShotSellAtLastPrice`) needs that ask off the + /// core's record (`Deal::pre_spike_ask`) — the tape's print before the spike sits 0.1–0.5 % + /// under the book's ask on a dump, and on 88 stopped MoonShot trades (2026-09-23) a take + /// placed off it was touched before the core's stop on 30, turning a loss into a win; + /// - every other kind takes `SellPrice`, known by construction. + /// + /// `false` is not "the model was wrong": it is "this trade's take is not modelled here", and + /// [`crate::db::tuner::ticks::verify`] then answers the exit group with nothing — which keeps + /// the trade out of the search (`record::fit_for_search`). + pub fn take_known(&self, deal: &Deal) -> bool { + let positive = |v: Option| v.is_some_and(|x| x.is_finite() && x > 0.0); + if deal.kind == KIND_MOONHOOK { + return !self.params.hook_sell_fixed + && self.params.hook_sell_level_pct > 0.0 + && positive(deal.hook_depth_pct); + } + if take_is_recorded(&deal.kind) { + return positive(deal.archived_take); + } + if take_model_for(&deal.kind) && self.params.sell_at_last_price { + return positive(deal.pre_spike_ask); + } + true + } +} + +/// The kind name of Spread as the strategy list spells it. +pub const KIND_SPREAD: &str = "Spread"; + +/// Whether the kind's take is not a rule of its parameters but a level the core took off the +/// detect — the spread it detected. `SellPrice` places it on 5 of 157 archived Spread takes +/// (2026-09-23) — the coincidences it takes for the width of a spread to land on the field. The +/// report's `comment` keeps only the width, rounded to 0.1 %, so the level is the take the order +/// archive recorded or nothing, for the fact and for every variant alike; and `SellPrice` is no +/// knob of the kind (`params::TICK_PARAMS`). +pub fn take_is_recorded(kind: &str) -> bool { + kind == KIND_SPREAD +} + +/// Whether the take the model computes for the kind is the kind's OWN rule, rather than the +/// general `SellPrice` — true for MoonShot, whose `MShotSellAtLastPrice` lift belongs to it +/// alone. For the rest the verdict prefers the archived level when the trade has one +/// (`Deal::archived_take`, `ExitParams::take_from_archive`), because the core's placed level +/// carries the delta modifiers exactly as the core applied them. +/// +/// It is NOT the test for "is the take known at all" — that is [`ExitModel::take_known`], and +/// reading this one in its place silenced five legitimate verdicts on the live sample +/// (2026-09-22), four of them hits: `SellPrice` is the take of every kind but MoonHook, and a +/// Spread trade without an archived line is judged by it perfectly well. +pub fn take_model_for(kind: &str) -> bool { + crate::db::tuner::ticks::entry::entry_model_for(kind) +} + +/// The take as an archived Exit line records it: its first point, when it is a price. +pub fn archived_take(exit_points: Option<&[(i64, f64)]>) -> Option { + let (_, take) = exit_points?.first().copied()?; + (take.is_finite() && take > 0.0).then_some(take) +} + +/// What `MShotSellAtLastPrice` multiplies the ask by to place the take: `1 − adjust/100` for a +/// long, `1/(1 − adjust/100)` for a short, whose take sits below the entry and is adjusted UP +/// toward it (the core developer, 2026-09-23: `max(Y·(1 − adj/100), …)` and +/// `min(Y/(1 − adj/100), …)`). `None` for an adjustment of 100 % or more, which leaves no price. +pub fn ask_take_factor(params: &ExitParams, is_short: bool) -> Option { + let keep = 1.0 - params.sell_price_adjust_pct / 100.0; + // NaN included: an adjustment the strategy did not spell as a number leaves no price. + if keep.is_nan() || keep <= 0.0 { + return None; + } + Some(if is_short { 1.0 / keep } else { keep }) +} + +/// The pre-spike ask behind an archived Exit line: its first point is the take as the core +/// placed it, the ask times [`ask_take_factor`] when `MShotSellAtLastPrice` placed it, so the +/// ask is that point with the factor divided out. The ask's branch carries no delta modifier +/// (the core developer, 2026-09-23), so the ask read back is the core's own, to the price step. +/// `None` when the rule was off (the take came from `SellPrice`, and the archive says nothing +/// about the ask), when the archive holds no Exit line, or when the first point is not a price. +/// +/// When `SellPrice` alone set the take farther than the ask would have, the division reads a +/// slightly high ask back — and the same `max` (a long) or `min` (a short, whose take sits +/// below the entry) puts the take on `SellPrice` again, so the trade's own replay is exact +/// either way; a variant with a smaller adjustment inherits the overread. On the live sample +/// (2026-09-23) the ask placed 776 of 785 archived MoonShot takes. +/// +/// Args: +/// exit_points: The archived Exit line's `(t_ms, price)` points, in the archive's order. +/// params: The sell-line parameters as of the trade. +/// is_short: The trade's side — which way the adjustment went. +pub fn archived_pre_spike_ask( + exit_points: Option<&[(i64, f64)]>, + params: &ExitParams, + is_short: bool, +) -> Option { + if !params.sell_at_last_price { + return None; + } + let factor = ask_take_factor(params, is_short)?; + let (_, take) = exit_points?.first().copied()?; + (take.is_finite() && take > 0.0).then_some(take / factor) +} + +/// The last print at least `lookback_ms` ([`crate::db::tuner::ticks::mshot::PRE_SPIKE_LOOKBACK_MS`] +/// by default) before `at_ms` — the FAQ's "price before the spike". +pub fn pre_spike_price(ticks: &[Tick], at_ms: i64, lookback_ms: i64) -> Option { + let cutoff = at_ms - lookback_ms; + ticks + .iter() + .rev() + .find(|t| (t.time_ms as i64) <= cutoff && t.price > 0.0) + .map(|t| f64::from(t.price)) +} + +/// The terminal's own floor on a step delay of zero: the FAQ's "0.33 s internal minimum". +pub const STEP_FLOOR_MS: i64 = 330; + +/// Seconds to milliseconds, with the terminal's floor (`ModelSettings::step_floor_ms`, +/// [`STEP_FLOOR_MS`] by default) for a zero delay. +pub(in crate::db::tuner::ticks) fn step_ms(seconds: f64, floor_ms: i64) -> i64 { + let ms = (seconds * 1000.0) as i64; + if ms <= 0 { floor_ms } else { ms } +} + +/// When the take is placed: `SellDelay` after the fill. Prints inside the delay cannot fill it, +/// and the take-timed rules (the pump move) count from here. +pub(super) fn armed_at(fill: Fill, params: &ExitParams) -> i64 { + fill.t_ms + params.sell_delay_ms.max(0.0) as i64 +} + +/// PriceDown's clock and floor. +pub(super) struct PriceDown<'a> { + params: &'a ExitParams, + fill: Fill, + side: Side, + /// When the next step is due; `None` when the rule is off or reached its floor. + next: Option, + /// `PriceDownAllowedDrop` over the buy. + floor: f64, + /// `PriceDownDelay`, with the terminal's floor. + delay_ms: i64, + /// The core's own lag between a step going through and the next one's timer + /// (`Deal::step_lag_ms`). + lag_ms: i64, +} + +impl<'a> PriceDown<'a> { + pub(super) fn new(params: &'a ExitParams, deal: &Deal, fill: Fill, side: Side) -> Self { + let pd_on = params.price_down_timer_s > 0.0 && params.price_down_pct > 0.0; + Self { + params, + fill, + side, + next: pd_on.then(|| fill.t_ms + (params.price_down_timer_s * 1000.0) as i64), + floor: side.over(fill.price, params.price_down_allowed_drop_pct), + delay_ms: step_ms(params.price_down_delay_s, params.model.step_floor_ms), + lag_ms: deal.step_lag_ms.max(0.0) as i64, + } + } + + /// The step due by the print at `t_ms`, if any. + pub(super) fn due(&self, t_ms: i64) -> Option { + due_by(self.next, t_ms) + } + + /// The step due at `due`: the line lowered by `PriceDownPercent` from where it stands, + /// never below the floor; at the floor the rule stops. + pub(super) fn step(&mut self, due: i64, line: &mut Line) { + let (params, fill, side) = (self.params, self.fill, self.side); + let core = line.core(); + let next = if params.price_down_relative { + core - (core - fill.price) * params.price_down_pct / 100.0 + } else { + core - side.over(fill.price, params.price_down_pct) + fill.price + }; + let next = side.farther(next, self.floor); + if (next - core).abs() <= f64::EPSILON * core.abs() { + self.next = None; + return; + } + // The core times the next step from this one's going through (`Deal::step_lag_ms`); + // a step rounding kept in place sent nothing and waits for nothing — over the + // archived GateF lines two delays with such a step between them run 32 ms over, + // against 47 ms for one real step. + let moved = line.place(due, next); + let lag_ms = if moved { self.lag_ms } else { 0 }; + self.next = Some(due + self.delay_ms + lag_ms); + } +} + +/// SellLevel's clock, count and floor. +pub(super) struct SellLevel<'a> { + params: &'a ExitParams, + fill: Fill, + side: Side, + /// When the next move is due; `None` when the rule is off or done. + next: Option, + /// The spacing of the moves after the first. + every_ms: i64, + /// Moves left of `SellLevelCount`. + left: u32, + /// The end of `SellLevelWorkTime`, `None` without one. + until: Option, + /// `SellLevelAllowedDrop` over the buy. + floor: f64, +} + +impl<'a> SellLevel<'a> { + pub(super) fn new(params: &'a ExitParams, fill: Fill, side: Side) -> Self { + let floor_ms = params.model.step_floor_ms; + let sl_on = params.sell_level_delay_s != 0.0 + && params.sell_level_time_s > 0.0 + && params.sell_level_count > 0; + let sl_first_ms = if params.sell_level_delay_s < 0.0 { + floor_ms + } else { + (params.sell_level_delay_s * 1000.0) as i64 + }; + let every_ms = if params.sell_level_delay_next_s > 0.0 { + (params.sell_level_delay_next_s * 1000.0) as i64 + } else if params.sell_level_delay_next_s < 0.0 { + floor_ms + } else { + sl_first_ms.max(floor_ms) + }; + Self { + params, + fill, + side, + next: sl_on.then(|| fill.t_ms + sl_first_ms), + every_ms, + left: params.sell_level_count, + until: (params.sell_level_work_time_s > 0.0) + .then(|| fill.t_ms + (params.sell_level_work_time_s * 1000.0) as i64), + floor: side.over(fill.price, params.sell_level_allowed_drop_pct), + } + } + + /// Every move due by the print at `t_ms`: to the high of the look-back, adjusted. + /// + /// Args: + /// seen: The prints up to and including the one at `t_ms`. + pub(super) fn catch_up(&mut self, t_ms: i64, seen: &[Tick], line: &mut Line) { + let (params, fill, side) = (self.params, self.fill, self.side); + while let Some(due) = due_by(self.next, t_ms) { + if self.left == 0 || self.until.is_some_and(|until| due > until) { + self.next = None; + break; + } + let from = due - (params.sell_level_time_s * 1000.0) as i64; + if let Some(high) = side.extreme_between(seen, from, due) { + let next = if params.sell_level_relative { + fill.price + (high - fill.price) * params.sell_level_adjust_pct / 100.0 + } else { + side.over(high, params.sell_level_adjust_pct) + }; + let next = side.farther(next, self.floor); + line.place(due, next); + } + self.left -= 1; + self.next = Some(due + self.every_ms); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs new file mode 100644 index 00000000..930bcc0e --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs @@ -0,0 +1,195 @@ +//! The Sell order section on synthetic tapes: the take, PriceDown, SellLevel. + +use super::*; +use crate::db::tuner::ticks::ExitKind; +use crate::db::tuner::ticks::exit::line::walk; +use crate::db::tuner::ticks::exit::tests::{deal, fill, params, tape}; + +// ---- the take off the archive ---------------------------------------------------------------- + +/// `MShotSellAtLastPrice` reads the book's ask, which the tape has not; the archive's first +/// Exit point gives it back with the trade's own adjustment divided out, and a caller that +/// recovered it gets a take the tape alone would have put 0.5 % lower. +#[test] +fn the_archived_ask_sets_the_take_where_the_core_placed_it() { + let p = ExitParams { + sell_price_pct: 1.5, + sell_at_last_price: true, + sell_price_adjust_pct: 0.2, + ..params() + }; + // GSTOCKBSC, 2026-09-21: the take as placed, 0.031603, is the ask 0.0316663 less 0.2 %. + let ask = archived_pre_spike_ask(Some(&[(0, 0.031603), (1_266, 0.030910)]), &p, false) + .expect("the rule was on"); + assert!((ask - 0.031603 / 0.998).abs() < 1e-12); + let mut d = deal(false); + d.buy_price = 0.029292; + d.pre_spike_ask = Some(ask); + let f = Fill { + t_ms: 0, + price: 0.029292, + }; + // The tape's own pre-spike print sits 0.5 % under the ask. + let ticks = tape(&[(-5_000, 0.031506), (1_000, 0.0300)]); + let take = ExitModel::new(&p).take_level(&d, &ticks, f); + assert!((take - 0.031603).abs() < 1e-9, "{take}"); + d.pre_spike_ask = None; + let from_tape = ExitModel::new(&p).take_level(&d, &ticks, f); + assert!((from_tape - 0.031506 * 0.998).abs() < 1e-7, "{from_tape}"); + // With the rule off the archive says nothing about the ask. + let off = ExitParams { + sell_at_last_price: false, + ..p.clone() + }; + assert_eq!( + archived_pre_spike_ask(Some(&[(0, 0.031603)]), &off, false), + None + ); + assert_eq!(archived_pre_spike_ask(None, &p, false), None); + // A short's take is the ask adjusted UP toward the entry, `ask / (1 − 0.2 %)`: the ask is + // the take times 0.998. + let short_ask = archived_pre_spike_ask(Some(&[(0, 0.031603)]), &p, true).expect("on"); + assert!((short_ask - 0.031603 * 0.998).abs() < 1e-12); +} + +/// A short MoonShot's take is divided off the fill, `fill / (1 + SellPrice/100)`, and the ask's +/// branch placed at `ask / (1 − adjust/100)` when it is the lower (the core developer, +/// 2026-09-23; ONE and BCH_RP on the archive). +#[test] +fn a_short_moonshot_take_divides_off_the_fill() { + let p = ExitParams { + sell_price_pct: 1.0, + ..params() + }; + let take = ExitModel::new(&p).take_level(&deal(true), &[], fill()); + assert!((take - 100.0 / 1.01).abs() < 1e-9, "{take}"); + let lifted = ExitParams { + sell_at_last_price: true, + sell_price_adjust_pct: 1.0, + ..p + }; + let mut d = deal(true); + d.pre_spike_ask = Some(97.0); + let take = ExitModel::new(&lifted).take_level(&d, &[], fill()); + assert!((take - 97.0 / 0.99).abs() < 1e-9, "{take}"); + // A MoonHook short keeps the product: its stored take cannot tell the two apart. + let mut hook = deal(true); + hook.kind = "MoonHook".into(); + let take = ExitModel::new(&p).take_level(&hook, &[], fill()); + assert!((take - 99.0).abs() < 1e-9, "{take}"); +} + +// ---- PriceDown ------------------------------------------------------------------------------- + +#[test] +fn price_down_steps_the_line_toward_the_buy_on_the_timer() { + // Take 101. Timer 1 s, then every 1 s, 50 % of the remaining distance (relative): 100.5 + // at t=1000, 100.25 at t=2000, floored at +0.1 % = 100.1. + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.1, + ..params() + }; + let ticks = tape(&[ + (500, 100.0), + (1_500, 100.0), + (2_500, 100.0), + (3_500, 100.0), + (4_500, 100.0), + (5_000, 100.2), + ]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + let levels: Vec = w.points.iter().map(|pt| pt.price).collect(); + assert!((levels[0] - 101.0).abs() < 1e-9); + assert!((levels[1] - 100.5).abs() < 1e-9, "{levels:?}"); + assert!((levels[2] - 100.25).abs() < 1e-9, "{levels:?}"); + assert!((levels[3] - 100.125).abs() < 1e-9, "{levels:?}"); + assert!((levels[4] - 100.1).abs() < 1e-9, "the floor: {levels:?}"); + assert_eq!(levels.len(), 5, "no step past the floor"); + // The print at 100.2 at t=5000 crosses the line at 100.1. + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 5_000)); + assert!((w.exit.price - 100.1).abs() < 1e-9); +} + +#[test] +fn price_down_absolute_takes_a_share_of_the_price() { + // SellPrice 1 %, pct 0.2 absolute: 101 -> 100.8 (of the buy price). + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 0.2, + price_down_relative: false, + price_down_allowed_drop_pct: 0.0, + ..params() + }; + let ticks = tape(&[(1_500, 100.0), (2_000, 100.9)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert!((w.points[1].price - 100.8).abs() < 1e-9, "{:?}", w.points); + assert_eq!(w.exit.kind, ExitKind::Line); +} + +#[test] +fn a_zero_timer_never_starts_the_steps() { + let p = ExitParams { + price_down_timer_s: 0.0, + price_down_pct: 50.0, + ..params() + }; + let ticks = tape(&[(5_000, 100.0), (60_000, 100.5)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert_eq!(w.points.len(), 1); + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd, "nothing closed it"); +} + +#[test] +fn a_zero_delay_steps_at_the_terminal_floor() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 0.0, + price_down_allowed_drop_pct: -1.0, + ..params() + }; + let ticks = tape(&[(1_000, 100.0), (1_660, 100.0)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + // t=1000, 1330, 1660: three steps by the second print. + assert_eq!(w.points.len(), 4, "{:?}", w.points); + assert_eq!(w.points[2].t_ms, 1_330); +} + +// ---- SellLevel ------------------------------------------------------------------------------- + +#[test] +fn sell_level_moves_to_the_look_back_high_adjusted() { + // Delay 2 s, look back 10 s, adjust -1 %: the high of the last 10 s at t=2000 is 103 -> + // 101.97; once (count 1). + let p = ExitParams { + sell_level_delay_s: 2.0, + sell_level_time_s: 10.0, + sell_level_count: 1, + sell_level_adjust_pct: -1.0, + sell_level_allowed_drop_pct: 0.0, + ..params() + }; + let ticks = tape(&[(500, 103.0), (1_000, 100.5), (2_000, 100.5), (3_000, 102.0)]); + let w = walk(&deal(false), &ticks, fill(), 105.0, &p); + assert!((w.points[1].price - 101.97).abs() < 1e-9, "{:?}", w.points); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 3_000)); +} + +#[test] +fn sell_level_relative_takes_a_share_of_the_distance_to_the_buy() { + let p = ExitParams { + sell_level_delay_s: 1.0, + sell_level_time_s: 10.0, + sell_level_count: 1, + sell_level_adjust_pct: 50.0, + sell_level_relative: true, + ..params() + }; + let ticks = tape(&[(500, 110.0), (1_000, 100.0)]); + let w = walk(&deal(false), &ticks, fill(), 120.0, &p); + assert!((w.points[1].price - 105.0).abs() < 1e-9, "{:?}", w.points); +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_shot.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_shot.rs new file mode 100644 index 00000000..cbae40fc --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_shot.rs @@ -0,0 +1,118 @@ +//! The strategy window's "Sell order / SellShot" section. +//! +//! **SellShot** (`IgnoreSellShot` off, `SellShotDistance` non-zero) — after `SellShotDelay` the +//! sell keeps `SellShotDistance` per cent off the highest print of the last +//! `SellShotCalcInterval` seconds, re-placed when its distance leaves the corridor +//! `Distance · (1 ± Corridor/100)`: after `SellShotRaiseWait` when moving away from the buy, +//! after `SellShotReplaceDelay` when moving toward it; `SellShotPriceDown` narrows the distance +//! by that much per second past `SellShotPriceDownDelay`; the line stays between +//! `SellShotAllowedDown` and `SellShotAllowedUp` per cent over the buy. +//! +//! From the Moonbot FAQ (`SellShot*` answers). Not checked on the tape: on 2026-09-20 +//! `IgnoreSellShot` was on in all live strategies but two. + +use super::line::Line; +use super::{ExitParams, Side}; +use crate::db::tuner::ticks::Fill; +use crate::db::tuner::ticks::mshot::FAST_ALGO_WINDOW_MS; +use crate::feed::types::Tick; + +/// SellShot's window, bounds and the corridor breach it is waiting out. +pub(super) struct SellShot<'a> { + params: &'a ExitParams, + fill: Fill, + side: Side, + /// Whether the strategy switched the rule on. + on: bool, + /// The end of `SellShotDelay`. + from: i64, + /// The calculation window. + calc_ms: i64, + /// `SellShotAllowedDown` over the buy. + low: f64, + /// `SellShotAllowedUp` over the buy. + high: f64, + /// `(kind, since)`: which way the line is out of the corridor and since when. + breach: Option<(bool, i64)>, +} + +impl<'a> SellShot<'a> { + pub(super) fn new(params: &'a ExitParams, fill: Fill, side: Side) -> Self { + Self { + params, + fill, + side, + on: !params.ignore_sell_shot && params.sell_shot_distance_pct != 0.0, + from: fill.t_ms + (params.sell_shot_delay_s.max(0.0) * 1000.0) as i64, + // The core's own floor on the SellShot calculation window — the same 100 ms its fast + // algorithm reads, but a rule of the sell, not the entry's re-place window + // (`ModelSettings::replace_window_ms`), and not a setting of the model. + calc_ms: ((params.sell_shot_calc_interval_s.max(0.0) * 1000.0) as i64) + .max(FAST_ALGO_WINDOW_MS), + low: side.over(fill.price, params.sell_shot_allowed_down_pct), + high: side.over(fill.price, params.sell_shot_allowed_up_pct), + breach: None, + } + } + + /// The line follows the market inside its corridor — driven by the print at `t_ms`. + /// + /// Args: + /// seen: The prints up to and including the one at `t_ms`. + pub(super) fn on_print(&mut self, t_ms: i64, seen: &[Tick], line: &mut Line) { + if !self.on || t_ms < self.from { + return; + } + let (params, fill, side) = (self.params, self.fill, self.side); + let from = t_ms - self.calc_ms; + let reference = side.extreme( + seen.iter() + .filter(|t| (t.time_ms as i64) >= from && t.price > 0.0) + .map(|t| f64::from(t.price)), + ); + let Some(reference) = reference else { + return; + }; + let elapsed_s = (t_ms - fill.t_ms) as f64 / 1000.0; + let mut distance = params.sell_shot_distance_pct; + if params.sell_shot_price_down < 0.0 { + let past = (elapsed_s - params.sell_shot_price_down_delay_s).max(0.0); + distance -= params.sell_shot_price_down.abs() * past; + } + let corridor = distance.abs() * params.sell_shot_corridor_pct / 100.0; + let d = side.distance_pct(reference, line.core()); + let out = if d > distance + corridor { + Some(false) // too far from the market: move toward the buy + } else if d < distance - corridor { + Some(true) // too close: move away from the buy + } else { + None + }; + match out { + None => self.breach = None, + Some(away) => { + let since = match self.breach { + Some((seen, since)) if seen == away => since, + _ => { + self.breach = Some((away, t_ms)); + t_ms + } + }; + let wait_ms = if away { + (params.sell_shot_raise_wait_s * 1000.0) as i64 + } else { + (params.sell_shot_replace_delay_s * 1000.0) as i64 + }; + if t_ms - since >= wait_ms { + let next = side.over(reference, distance); + let next = side.nearer(side.farther(next, self.low), self.high); + line.place(t_ms, next); + self.breach = None; + } + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_shot/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_shot/tests.rs new file mode 100644 index 00000000..8964cf86 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_shot/tests.rs @@ -0,0 +1,45 @@ +//! The SellShot section on synthetic tapes. + +use super::*; +use crate::db::tuner::ticks::ExitKind; +use crate::db::tuner::ticks::exit::line::walk; +use crate::db::tuner::ticks::exit::tests::{deal, fill, params, tape}; + +// ---- SellShot --------------------------------------------------------------------------------- + +#[test] +fn sell_shot_follows_the_market_inside_its_corridor() { + // Distance 1 %, corridor 50 %: the line stays while 0.5-1.5 % off the reference. The + // take at 101 is 1 % off 100; a rise to 100.8 leaves it 0.2 % off -> too close -> after + // the raise wait (0) it moves away to 101.808. + let p = ExitParams { + ignore_sell_shot: false, + sell_shot_distance_pct: 1.0, + sell_shot_corridor_pct: 50.0, + sell_shot_calc_interval_s: 0.1, + sell_shot_allowed_up_pct: 10.0, + sell_shot_allowed_down_pct: -1.0, + ..params() + }; + let ticks = tape(&[(500, 100.0), (1_000, 100.8), (1_500, 101.5)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert!((w.points[1].price - 101.808).abs() < 1e-4, "{:?}", w.points); + // 101.5 stays under the moved line, and the tape ends before the report's close. + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd); +} + +#[test] +fn sell_shot_is_capped_by_allowed_up() { + let p = ExitParams { + ignore_sell_shot: false, + sell_shot_distance_pct: 1.0, + sell_shot_corridor_pct: 50.0, + sell_shot_calc_interval_s: 0.1, + sell_shot_allowed_up_pct: 0.5, + sell_shot_allowed_down_pct: -1.0, + ..params() + }; + let ticks = tape(&[(500, 100.0), (1_000, 100.8)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert!((w.points[1].price - 100.5).abs() < 1e-4, "{:?}", w.points); +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_spread.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_spread.rs new file mode 100644 index 00000000..92935d2b --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_spread.rs @@ -0,0 +1,5 @@ +//! The strategy window's "Sell order / SellSpread" section — not modelled yet. +//! +//! No live strategy switched it on (2026-09-23), so there is no trade to read the core's +//! behaviour off and none the model would judge differently. The section's fields are +//! `Outside` rows of the grid until its rule lands here. diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops.rs new file mode 100644 index 00000000..aef1a70f --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops.rs @@ -0,0 +1,502 @@ +//! The strategy window's "Stops" section: `StopLoss`, `StopLossDelay`, `UseStopLoss`, +//! `FastStopLoss`, `StopLossEMA` and `StopLossModifier`. +//! +//! **StopLoss** — `StopLoss` per cent from the buy (negative: a loss), adjusted by +//! `StopLossModifier` ([`stop_pct`]), armed `StopLossDelay` seconds after the buy; `UseStopLoss` +//! off zeroes it (`params::exit_params`). With `FastStopLoss` the first print through it is a +//! market exit at the print's own price. Without it — the core's default — the core watches the +//! REST ticker's BID (a short's ASK), a long's averaged over `StopLossEMA` of the ticker's +//! arrivals, and the walk reads a proxy of that: the last print on that side of the book (a +//! taker sell prints at the BID), sampled every [`TICKER_PERIOD_MS`], averaged the same way from +//! before the fill on; at `StopLossEMA` 0 the core's price series fires it too +//! ([`SERIES_TICK_MS`]). The exit is at the sample, at the proxy's price. Where the core's panic +//! sell then fills is the book's business — see [`crate::db::tuner::ticks::verify`] for how the +//! fact is judged. +//! +//! The trailing stop (`UseTrailing`) is [`trailing`]; the stop ladder (`UseSecondStop`, +//! `UseStopLoss3`) is not modelled ([`super::UnmodelledRule`]). + +pub mod trailing; + +use self::trailing::Trailing; +use super::delta_mods::modifier_sum; +use super::{ExitParams, Side}; +use crate::db::tuner::ticks::{Deal, Exit, ExitKind, Fill, reaches}; +use crate::feed::types::Tick; + +/// How often the core's REST ticker brings the BID (a short's ASK) the non-fast stop watches, +/// and so how often the walk samples its proxy. The core developer (2026-09-23): the stop reads +/// the ticker, not the book and not the trades; the ticker arrives every ~2–2.3 s (Gate's spot +/// half a second slower, about once a second around each hour's turn); the stop is checked +/// every ~0.1 s but the price only moves with the ticker, so it fires on the first arrival +/// that puts it past the level. The middle of that range: over the 192 live book-watching +/// stops of 2026-09-23, 2 000–2 300 ms move the stops judged on time by ±5, the noise of a +/// phase no record keeps. +pub const TICKER_PERIOD_MS: i64 = 2_150; + +/// The core's price-series tick: one point per 250 ms, of the prints the tick brought the one +/// closest to the previous point (`docs-internal/STRATEGY_FORMULAS/deltas.md` §7). A stop at +/// `StopLossEMA` 0 fires on that series as well as on the ticker's price (the core developer, +/// 2026-09-23) — a lone print in its tick is a point, a spike among prints near the last +/// point is not. +pub const SERIES_TICK_MS: i64 = 250; + +/// The stop distance of a trade, per cent: `StopLoss` adjusted by `StopLossModifier · Σ`. +/// +/// Normally that deepens the stop (a positive coefficient over a positive delta sum), but +/// neither sign is guaranteed: live strategies carry `StopLossModifier` down to −0.3, and a +/// delta sum can be negative, so the adjustment can also pull the stop TOWARD the entry. +/// +/// An adjustment big enough to pull it THROUGH the entry answers `0.0` — no stop on this trade +/// — rather than a level. Clamping it to a hair's breadth from the entry instead would fire on +/// the first print that moves, which is not a stop but a coin flip dressed as one; and placing +/// it beyond the entry would fire on the first print, full stop. What the core does with an +/// adjustment that large is unknown: none of the 1 735 replayed trades reaches this branch, so +/// the model declines to invent an answer. A configured stop on the profit side (`StopLoss` positive — +/// live data has it) is a different thing and is left exactly as configured. +/// +/// Args: +/// params: The sell parameters. +/// deal: The trade, for its deltas. +/// at_ms: When the sell was placed — the fill; see [`modifier_sum`]. +pub fn stop_pct(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { + if params.stop_loss_pct == 0.0 || params.stop_loss_modifier == 0.0 { + return params.stop_loss_pct; + } + let adjusted = + params.stop_loss_pct - modifier_sum(params, deal, at_ms) * params.stop_loss_modifier; + // Same side as configured, or nothing at all. + if adjusted == 0.0 || adjusted.is_sign_negative() != params.stop_loss_pct.is_sign_negative() { + return 0.0; + } + adjusted +} + +/// The stop's price: `pct` per cent ([`stop_pct`]) off the buy — `buy·(1 + pct/100)` for a long, +/// `buy/(1 + pct/100)` for a short, not the long's product mirrored. The core prints the level +/// into its reason (`StopLoss fixed: X`): over the report (2026-09-24) the division lands on it +/// for 11 099 short stops against 25 for the mirror, the product for 20 930 long ones against 12 +/// — the adjusted distance of `StopLossModifier` included. At `−2.5 %` the two short readings +/// part by 0.06 % of the price. +/// +/// A loss of 100 % or more leaves no price to stop at: 0 for a long and `f64::INFINITY` for a +/// short, levels no print reaches. +/// +/// Args: +/// buy: The buy the stop counts from. +/// pct: The stop distance, negative on the losing side. +/// long: The trade's side. +pub fn stop_level(buy: f64, pct: f64, long: bool) -> f64 { + let keep = 1.0 + pct / 100.0; + match (long, keep <= 0.0) { + (true, true) => 0.0, + (true, false) => buy * keep, + (false, true) => f64::INFINITY, + (false, false) => buy / keep, + } +} + +/// The weight of a new ticker price in the average the non-fast stop watches, when the core +/// keeps one: `avg = (avg·(N − 1) + bid) / N`, a weight of `1/N`, for a LONG at `StopLossEMA` +/// 3, 5 or 10 only. Any other value — 7 included — and every short watch the bare price (the +/// core developer, 2026-09-23). The FAQ's "average over the last 3, 5, 10 ticks" read as an +/// EMA of `2/(N + 1)` forgot the price before a dump twice as fast, and fired early. +fn stop_average_weight(params: &ExitParams, long: bool) -> Option { + let n = params.stop_loss_ema; + let whole = (n - n.round()).abs() < 1e-9; + (long && whole && matches!(n.round() as i64, 3 | 5 | 10)).then(|| 1.0 / n) +} + +/// The book-watching stop's state: a ticker-price proxy — the last print on the stop's side of +/// the book — sampled every [`TICKER_PERIOD_MS`] and, for a long at `StopLossEMA` 3, 5 or 10, +/// averaged ([`stop_average_weight`]); at `StopLossEMA` 0 the core's price series as well. +/// +/// The core keeps the average for every market from its start, so at the fill it is warm: +/// after a dump it still remembers the prices above, and fires later than a fresh one. The +/// walk feeds it the prints before the fill for that — they move the average and can never +/// fire it. +struct BookStop { + long: bool, + level: f64, + /// The end of `StopLossDelay`: a sample before it is averaged but cannot fire. + armed_at: i64, + /// The fill: a sample or a series point up to it only warms the state. + fill_ms: i64, + /// The average's weight, `None` for the bare price. + weight: Option, + proxy: Option, + avg: Option, + next_sample: i64, + /// The ticker's period (`ModelSettings::ticker_period_ms`), at least a millisecond. + period_ms: i64, + /// Up to when the fact proves this stop did not fire (`record::StopAnchor`): a sample by + /// then is averaged but cannot fire. `i64::MIN` when the walk is not the trade's own stop. + quiet_until: i64, + /// The price series a stop at `StopLossEMA` 0 also fires on. + series: Option, +} + +/// The core's price series as the stop reads it (see [`SERIES_TICK_MS`]). +struct SeriesPoint { + /// The series' last point. + point: Option, + /// Of the prints the open tick brought, the one closest to `point`. + candidate: Option, + /// When the open tick closes; every pending print is before it. + tick_end: i64, + /// The tick's length (`ModelSettings::series_tick_ms`), at least a millisecond. + tick_ms: i64, +} + +impl SeriesPoint { + fn new(first_ms: i64, tick_ms: i64) -> Self { + Self { + point: None, + candidate: None, + tick_end: next_series_tick(first_ms, tick_ms), + tick_ms, + } + } + + /// Read a print into the open tick. + fn see(&mut self, price: f64) { + self.candidate = match (self.point, self.candidate) { + (Some(point), Some(held)) if (held - point).abs() <= (price - point).abs() => { + Some(held) + } + // Before the first point any print will do; the latest stands. + _ => Some(price), + }; + } +} + +/// The end of the series tick a print at `t_ms` falls in: the ticks run on the clock's +/// `tick_ms` boundaries ([`SERIES_TICK_MS`] by default), and a print ON a boundary opens the next +/// tick. +fn next_series_tick(t_ms: i64, tick_ms: i64) -> i64 { + let tick_ms = tick_ms.max(1); + (t_ms.div_euclid(tick_ms) + 1) * tick_ms +} + +impl BookStop { + /// Args: + /// long: The trade's side. + /// level: The stop level. + /// armed_at: The end of `StopLossDelay`. + /// fill_ms: The fill. + /// first_ms: The first print the walk will feed, before the fill when the tape reaches + /// back: the ticker's clock is run back to it so the average is warm at the fill. + /// params: The sell parameters, for `StopLossEMA`. + /// quiet_until: See the field. + fn new( + long: bool, + level: f64, + armed_at: i64, + fill_ms: i64, + first_ms: i64, + params: &ExitParams, + quiet_until: i64, + ) -> Self { + // One period past the fill, and back from there in whole periods: the ticker's phase + // is on no record, so the fill anchors it, however far back the tape reaches. + let period_ms = params.model.ticker_period_ms.max(1); + let anchor = fill_ms + period_ms; + let back = (anchor - first_ms).max(0) / period_ms; + Self { + long, + level, + armed_at, + fill_ms, + weight: stop_average_weight(params, long), + proxy: None, + avg: None, + next_sample: anchor - back * period_ms, + period_ms, + quiet_until, + series: (params.stop_loss_ema.abs() < 1e-9) + .then(|| SeriesPoint::new(first_ms, params.model.series_tick_ms)), + } + } + + fn may_fire(&self, at: i64) -> bool { + at > self.fill_ms && at >= self.armed_at && at > self.quiet_until + } + + /// Everything due before the print at `until` — the ticker's arrivals strictly before it, + /// the series ticks closing at or before it — and the earliest that put the stop past its + /// level: the stop, at that moment and that price. + fn before(&mut self, until: i64) -> Option { + let by_ticker = self.samples_before(until); + let by_series = self.series_before(until); + match (by_ticker, by_series) { + (Some(t), Some(s)) => Some(if s.t_ms < t.t_ms { s } else { t }), + (t, s) => t.or(s), + } + } + + /// The ticker's arrivals strictly before `until` — the prints before it are all the proxy + /// has seen — and the first whose price (averaged, when the core averages) is past the + /// level. + fn samples_before(&mut self, until: i64) -> Option { + while self.next_sample < until { + let at = self.next_sample; + self.next_sample += self.period_ms; + let Some(bid) = self.proxy else { + continue; + }; + let avg = match (self.weight, self.avg) { + (Some(w), Some(avg)) => w * bid + (1.0 - w) * avg, + _ => bid, + }; + self.avg = Some(avg); + if self.may_fire(at) && reaches(avg, self.level, self.long) { + return Some(stop_exit(at, bid)); + } + } + None + } + + /// The series tick the pending prints fall in, when it closes by `until`: its point, and + /// the stop when that point is strictly past the level. The ticks after it up to `until` + /// brought no print and add no point. + fn series_before(&mut self, until: i64) -> Option { + let series = self.series.as_mut()?; + if series.tick_end > until { + return None; + } + let at = series.tick_end; + series.tick_end = next_series_tick(until, series.tick_ms); + let point = series.candidate.take()?; + series.point = Some(point); + let past = if self.long { + point < self.level + } else { + point > self.level + }; + (past && self.may_fire(at)).then(|| stop_exit(at, point)) + } + + /// Read a print: into the series, and into the proxy when it hit the stop's side — a taker + /// sell prints at the BID, a long's stop side; a short's stop watches the ASK, where a + /// taker buy prints. + fn see(&mut self, tick: &Tick) { + let price = f64::from(tick.price); + if let Some(series) = self.series.as_mut() { + series.see(price); + } + let stop_side = if self.long { + crate::feed::types::Side::Sell + } else { + crate::feed::types::Side::Buy + }; + if tick.side == stop_side { + self.proxy = Some(price); + } + } +} + +/// What fires the trade's stop. +enum Trigger { + /// No stop: `StopLoss` 0, `UseStopLoss` off, or an adjustment that pulled it through the + /// entry ([`stop_pct`]). + Off, + /// `FastStopLoss`: the first print through the level, a market order the core fires on the + /// print. + Fast { + long: bool, + level: f64, + /// The end of `StopLossDelay`. + from: i64, + /// Up to when the fact proves the stop did not fire; `i64::MIN` when it proves nothing. + quiet_until: i64, + }, + /// The book-watching stop on its ticker proxy. + Book(BookStop), +} + +/// The stop as the walk runs it: the fast one on the prints, the book-watching one on its +/// ticker proxy, or the fact's own when the walk replays the trade's own stop — and the trailing +/// stop beside it. +pub(super) struct Stops { + trigger: Trigger, + /// `UseTrailing`'s line, when the strategy switched it on. + trailing: Option, + /// When and at what price the fact's own stop fired, when the walk runs the trade's own. + fired: Option<(i64, f64)>, +} + +impl Stops { + /// The trade's stop under `params`, the book-watching one warmed on the prints before the + /// fill. + pub(super) fn new( + deal: &Deal, + ticks: &[Tick], + fill: Fill, + params: &ExitParams, + side: Side, + ) -> Self { + // The ADJUSTED distance decides both whether there is a stop and where it stands — + // reading the raw `stop_loss_pct` for the first and the adjusted one for the second would + // arm a stop the adjustment had cancelled, at the fill price itself, where the next print + // fires it. [`stop_level`] puts the distance on the trade's side, so only the distance is + // adjusted here; the level is NOT snapped to the price grid, unlike every level that + // reaches the exchange — + // a stop is the core's own trigger for a market sell, and nothing about it is ever placed. + let stop = stop_pct(params, deal, fill.t_ms); + let stop_on = stop != 0.0; + let level = stop_level(fill.price, stop, side.long); + let stop_from = fill.t_ms + (params.stop_loss_delay_s.max(0.0) * 1000.0) as i64; + // What the fact proves about the stop when this walk runs the trade's own + // (`record::StopAnchor`): it fired when the core's did, at the price the core sold at, and + // not a moment before — nor before the close, on a trade it never stopped. The book the + // stop watches is not on the tape; the fact is the book's own answer. + let anchor = deal.stop_anchor.filter(|a| a.holds(deal, fill, params)); + let quiet_until = anchor.map_or(i64::MIN, |a| a.quiet_until_ms); + let fired = anchor.and_then(|a| a.fired); + // The ticker's clock of both proxies is run back to the tape's first print, so the stop's + // and the trailing's arrivals fall on the same moments. + let first_ms = ticks + .first() + .map_or(fill.t_ms, |t| (t.time_ms as i64).min(fill.t_ms)); + let before_fill = || { + ticks + .iter() + .take_while(|t| (t.time_ms as i64) <= fill.t_ms) + .filter(|t| t.price.is_finite() && t.price > 0.0) + }; + let trailing = Trailing::new( + side.long, + fill.price, + stop_from, + fill.t_ms, + first_ms, + params, + quiet_until, + ) + .map(|mut trailing| { + // The spread the prints before the fill leave; they never step the peak. + for tick in before_fill() { + let _warm_only = trailing.before(tick.time_ms as i64); + trailing.see(tick); + } + trailing + }); + let trigger = if !stop_on { + Trigger::Off + } else if params.fast_stop_loss { + Trigger::Fast { + long: side.long, + level, + from: stop_from, + quiet_until, + } + } else { + // The non-fast stop's ticker proxy: the last print on the stop's side of the book, + // sampled on the ticker's clock, averaged as the core averages (see `BookStop`) — + // warmed on the prints before the fill, which can never fire it. + let mut book = BookStop::new( + side.long, + level, + stop_from, + fill.t_ms, + first_ms, + params, + quiet_until, + ); + for tick in before_fill() { + let _warm_only = book.before(tick.time_ms as i64); + book.see(tick); + } + Trigger::Book(book) + }; + Self { + trigger, + trailing, + fired, + } + } + + /// The fact's own stop, when it fired by the print at `t_ms`. + pub(super) fn fired_by(&self, t_ms: i64) -> Option { + self.fired + .filter(|(at, _)| t_ms >= *at) + .map(|(at, sold)| stop_exit(at, sold)) + } + + /// The stop on the print at `t_ms`, `price`: the book stop's and the trailing's ticker + /// arrivals due BEFORE it — every sample reads the proxy the earlier prints left, every series + /// tick closing by it the points they left, and the earliest past its level fires at its own + /// moment, the stop first on the same moment (the core checks it first) — or the fast stop, a + /// market order the core fires on the print. + pub(super) fn on_print(&mut self, tick: &Tick, t_ms: i64, price: f64) -> Option { + let by_book = match &mut self.trigger { + Trigger::Book(book) => book.before(t_ms), + Trigger::Off | Trigger::Fast { .. } => None, + }; + let by_trailing = self + .trailing + .as_mut() + .and_then(|trailing| trailing.before(t_ms)); + if let Some(exit) = earliest(by_book, by_trailing) { + return Some(exit); + } + if let Trigger::Book(book) = &mut self.trigger { + book.see(tick); + } + if let Some(trailing) = self.trailing.as_mut() { + trailing.see(tick); + } + match &self.trigger { + Trigger::Fast { + long, + level, + from, + quiet_until, + } => (t_ms >= *from && t_ms > *quiet_until && reaches(price, *level, *long)) + .then(|| stop_exit(t_ms, price)), + Trigger::Off | Trigger::Book(_) => None, + } + } + + /// The stop past the tape's last print at `tail`: the fact's own — the tape went quiet, the + /// core did not — or the ticker's arrivals up to the tape's end, the one AT the last print + /// included, reading the proxies the last prints left; the walk only ever reaches the samples + /// before a print. + pub(super) fn after_tape(&mut self, tail: i64) -> Option { + if let Some((at, sold)) = self.fired { + return Some(stop_exit(at, sold)); + } + let by_book = match &mut self.trigger { + Trigger::Book(book) => book.before(tail + 1), + Trigger::Off | Trigger::Fast { .. } => None, + }; + let by_trailing = self + .trailing + .as_mut() + .and_then(|trailing| trailing.before(tail + 1)); + earliest(by_book, by_trailing) + } +} + +/// The earlier of the stop's and the trailing's exits; the stop on the same moment. +fn earliest(stop: Option, trailing: Option) -> Option { + match (stop, trailing) { + (Some(stop), Some(trailing)) => Some(if trailing.t_ms < stop.t_ms { + trailing + } else { + stop + }), + (stop, trailing) => stop.or(trailing), + } +} + +fn stop_exit(t_ms: i64, price: f64) -> Exit { + Exit { + t_ms, + price, + kind: ExitKind::Stop, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs new file mode 100644 index 00000000..2dcd4d86 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs @@ -0,0 +1,278 @@ +//! The Stops section on synthetic tapes: the fast stop, the book stop on its ticker proxy, and +//! how the verdict judges each. + +use super::*; +use crate::db::tuner::ticks::exit::line::walk; +use crate::db::tuner::ticks::exit::tests::{deal, fill, params, tape, tick}; +use crate::db::tuner::ticks::{EntryParams, verify}; +use crate::feed::types::Side as TickSide; + +// ---- StopLoss --------------------------------------------------------------------------------- + +#[test] +fn the_stop_fires_on_the_print_after_its_delay() { + let p = ExitParams { + stop_loss_pct: -1.0, + stop_loss_delay_s: 2.0, + ..params() + }; + // A print through the stop inside the delay does not fire; one after it does, at the + // print's price (a market exit). + let ticks = tape(&[(1_000, 98.5), (3_000, 98.7)]); + let w = walk(&deal(false), &ticks, fill(), 101.0, &p); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 3_000)); + assert!((w.exit.price - 98.7).abs() < 1e-4); +} + +/// A short's stop is the buy DIVIDED by `1 + StopLoss/100`, not the long's product mirrored: +/// `-2.5 %` off 100 is 102.564, so a print at 102.53 is still inside it. The core prints it that +/// way on 11 099 short stops of the report (`StopLoss fixed: X`) against 25 for the mirror. +#[test] +fn a_short_stop_divides_the_buy() { + let p = ExitParams { + stop_loss_pct: -2.5, + ..params() + }; + let inside = tape(&[(1_000, 102.53), (2_000, 102.57)]); + let w = walk(&deal(true), &inside, fill(), 99.0, &p); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 2_000), "{w:?}"); + assert!((stop_level(100.0, -2.5, false) - 100.0 / 0.975).abs() < 1e-9); + assert!((stop_level(100.0, -2.5, true) - 97.5).abs() < 1e-9); + // A stop on the profit side of a short sits below the buy, by the same division. + assert!((stop_level(100.0, 0.4, false) - 100.0 / 1.004).abs() < 1e-9); + // A loss of 100 % or more leaves a short no finite price to stop at. + assert_eq!(stop_level(100.0, -100.0, false), f64::INFINITY); +} + +fn sold(t_ms: i64, price: f64) -> Tick { + Tick { + side: TickSide::Sell, + ..tick(t_ms, price) + } +} + +/// A book-watching stop (`FastStopLoss` off) that reads the bare ticker price: `StopLossEMA` +/// neither 0 (which adds the series) nor 3, 5, 10 (which average). +fn bare_book_stop() -> ExitParams { + ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: false, + stop_loss_ema: 7.0, + ..params() + } +} + +/// The book-watching stop (`FastStopLoss` off) reads the ticker's BID through the prints that +/// hit it — taker sells — on the ticker's clock, not every print through the level. +#[test] +fn the_book_stop_fires_on_a_sample_of_the_bid_not_on_a_print() { + let book = bare_book_stop(); + // A taker BUY through the level says nothing about the BID; the taker sell at 98.8 does, + // and the next arrival after it — 4.3 s — fires, at the proxy's price. + let ticks = vec![ + tick(1_000, 98.5), + sold(1_500, 99.5), + sold(2_500, 98.8), + tick(5_000, 100.0), + ]; + let w = walk(&deal(false), &ticks, fill(), 101.0, &book); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, 2 * TICKER_PERIOD_MS) + ); + assert!((w.exit.price - 98.8).abs() < 1e-4); + // The fast stop takes the first print through the level, whichever side it hit. + let fast = ExitParams { + fast_stop_loss: true, + ..book.clone() + }; + let w = walk(&deal(false), &ticks, fill(), 101.0, &fast); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 1_000)); +} + +/// A sample due exactly at the tape's last print reads that print — the loop only reaches the +/// samples before a print, so the tape's end is where it must not be forgotten. +#[test] +fn the_book_stop_takes_the_sample_at_the_last_print() { + let book = bare_book_stop(); + let w = walk( + &deal(false), + &[sold(TICKER_PERIOD_MS, 98.8)], + fill(), + 101.0, + &book, + ); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, TICKER_PERIOD_MS) + ); + // A sample the tape ends before is not taken: nothing is known past the last print. + let w = walk(&deal(false), &[sold(1_500, 98.8)], fill(), 101.0, &book); + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd); +} + +/// `StopLossEMA` 3 averages the ticker's arrivals as the core does, `(avg·2 + bid)/3`, so a BID +/// just past the level fires only once the average is past it too. +#[test] +fn the_stop_ema_waits_for_the_average() { + let ticks = vec![ + sold(1_500, 99.5), + sold(2_500, 98.9), + sold(9_000, 98.9), + sold(13_000, 98.9), + ]; + let bare = bare_book_stop(); + let w = walk(&deal(false), &ticks, fill(), 101.0, &bare); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, 2 * TICKER_PERIOD_MS) + ); + // Arrivals 99.5, then 98.9 from the second on: the average reads 99.5, 99.3, 99.167, + // 99.078, 99.019, 98.979 — past 99 at the sixth. An EMA of 2/(N + 1) = 0.5 would have + // crossed at the fourth. + let smoothed = ExitParams { + stop_loss_ema: 3.0, + ..bare + }; + let w = walk(&deal(false), &ticks, fill(), 101.0, &smoothed); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, 6 * TICKER_PERIOD_MS) + ); +} + +/// The core keeps the average from its start, so at the fill it remembers the prices before it: +/// the prints before the fill warm it, and can never fire it. +#[test] +fn the_stop_average_is_warm_at_the_fill() { + let smoothed = ExitParams { + stop_loss_ema: 3.0, + ..bare_book_stop() + }; + let after = [sold(1_500, 98.9), sold(30_000, 98.9)]; + let cold = walk(&deal(false), &after, fill(), 101.0, &smoothed); + assert_eq!( + (cold.exit.kind, cold.exit.t_ms), + (ExitKind::Stop, TICKER_PERIOD_MS) + ); + // A minute at 101 before the fill: the average starts there, seven arrivals of 98.9 leave + // it at 101 · (2/3)^7 + 98.9 · (1 − (2/3)^7) = 99.02, and the eighth brings it to 98.98. + let mut warm: Vec = (0..30).map(|i| sold(-60_000 + i * 2_000, 101.0)).collect(); + warm.extend(after); + let w = walk(&deal(false), &warm, fill(), 101.0, &smoothed); + assert_eq!(w.exit.kind, ExitKind::Stop); + assert_eq!(w.exit.t_ms, 8 * TICKER_PERIOD_MS, "{w:?}"); + // The prints before the fill, past the level, fire nothing before it. + let early = vec![sold(-3_000, 98.0), tick(500, 100.0)]; + let w = walk(&deal(false), &early, fill(), 101.0, &bare_book_stop()); + assert!(w.exit.t_ms > 0, "{w:?}"); +} + +/// A short's book stop watches the bare ASK: `StopLossEMA` averages a long's BID only. +#[test] +fn a_short_book_stop_does_not_average() { + let smoothed = ExitParams { + stop_loss_ema: 3.0, + ..bare_book_stop() + }; + // A taker buy prints at the ASK: 100 before the fill, then 101.1 past a short's stop at 101 + // — which an average of the two, 100.37, would not be. + let ticks = vec![tick(-1_000, 100.0), tick(1_500, 101.1), tick(5_000, 100.0)]; + let w = walk(&deal(true), &ticks, fill(), 99.0, &smoothed); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Stop, TICKER_PERIOD_MS) + ); +} + +/// At `StopLossEMA` 0 the core's price series fires the stop too: a lone print past the level +/// is its tick's point, and fires when the tick closes; a spike among prints near the last +/// point is not the point, and waits for the ticker. +#[test] +fn a_stop_without_averaging_fires_on_the_series_point() { + let series = ExitParams { + stop_loss_ema: 0.0, + ..bare_book_stop() + }; + let lone = vec![tick(1_000, 98.5), tick(5_000, 100.0)]; + let w = walk(&deal(false), &lone, fill(), 101.0, &series); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 1_250)); + assert!((w.exit.price - 98.5).abs() < 1e-4); + // The same print after a point at 100 and beside 99.9 in its tick: the point is 99.9. + let spike = vec![ + tick(900, 100.0), + tick(1_000, 98.5), + tick(1_100, 99.9), + tick(5_000, 100.0), + ]; + let w = walk(&deal(false), &spike, fill(), 101.0, &series); + assert_ne!(w.exit.kind, ExitKind::Stop, "{w:?}"); + // At 7 the core reads the bare ticker price and no series. + let w = walk(&deal(false), &lone, fill(), 101.0, &bare_book_stop()); + assert_ne!(w.exit.kind, ExitKind::Stop, "{w:?}"); +} + +/// A book stop's sale is a panic sell walked through the book: the verdict holds the model's +/// stop level against the one the core printed, and the moment against the close — never the +/// sale price. A stop the core fired and the model never did is a miss. +#[test] +fn verify_judges_a_book_stop_by_its_level_and_moment() { + let book = ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: false, + ..params() + }; + let mut d = deal(false); + d.sell_reason = "StopLoss AutoActivated on price drop: BID = 98.800 ASK: 98.900 \ + (strategy ); StopLoss fixed: 99.000 AllowedDrop: BUY -15.0%" + .into(); + d.sell_price = 97.0; + d.close_ms = 4_050; + let ticks = vec![sold(1_500, 99.5), sold(2_500, 98.8), tick(5_000, 100.0)]; + let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit_kind, Some(ExitKind::Stop)); + assert_eq!(v.exit, Some(true), "{v:?}"); + assert!(v.exit_dev_pct.is_some_and(|dev| dev.abs() < 1e-9)); + // The stored reason cut inside the level: still the book stop, judged by its moment. + let full = d.sell_reason.clone(); + d.sell_reason = full[..full.find("99.000").expect("level") + 3].to_string(); + let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit, Some(true), "{v:?}"); + d.close_ms = 9_000; + let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit, Some(false), "five seconds off the moment, {v:?}"); + d.close_ms = 4_050; + d.sell_reason = full; + // The core's level elsewhere: not this stop. + d.sell_reason = d.sell_reason.replace("99.000", "98.000"); + let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit, Some(false), "{v:?}"); + // A tape whose BID never reaches the level: the core stopped, the model did not. + let calm = vec![sold(1_500, 99.5), tick(5_000, 100.0)]; + let v = verify(&d, &calm, &EntryParams::Fact, &book, None, None); + assert_eq!(v.exit, Some(false), "{v:?}"); +} + +/// A market stop's sale sweeps our size through the book: with no level on record — its reason +/// never carries one — the verdict holds the moment it fired, never the sweep's price. +#[test] +fn verify_judges_a_market_stop_without_a_level_by_its_moment() { + let fast = ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: true, + ..params() + }; + let mut d = deal(false); + d.sell_reason = "StopLoss Market Sell".into(); + // 1.4 % past the print that fired it: the sweep, which no print on the tape shows. + d.sell_price = 97.5; + d.close_ms = 3_100; + let ticks = tape(&[(1_000, 99.5), (3_000, 98.9), (5_000, 99.0)]); + let v = verify(&d, &ticks, &EntryParams::Fact, &fast, None, None); + assert_eq!(v.exit_kind, Some(ExitKind::Stop)); + assert_eq!(v.exit, Some(true), "{v:?}"); + assert_eq!(v.exit_dev_pct, None); + d.close_ms = 9_000; + let v = verify(&d, &ticks, &EntryParams::Fact, &fast, None, None); + assert_eq!(v.exit, Some(false), "six seconds off the moment, {v:?}"); +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing.rs new file mode 100644 index 00000000..a9e976b8 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing.rs @@ -0,0 +1,237 @@ +//! The trailing stop (`UseTrailing`) of the strategy window's "Stops" section. +//! +//! From the Moonbot documentation (moonbot.pro, Stops tab and "Trailing Stop") and the core's +//! answers of 2026-09-24 (`docs-internal/STRATEGY_FORMULAS/sell-common.md` §7): +//! +//! - The line follows the middle of the ticker's spread, `(bid + ask)/2`, and only rises: it +//! stands `TrailingPercent` (negative) under the peak of that middle — a multiplication, the one +//! distance of a short that is not a division off the buy. +//! - The peak steps at most once a second, and only when the middle is past it; with +//! `TrailingEMA` N a step moves it `1/(N + 1)` of the way to the middle, so a single spike does +//! not drag the line after it. +//! - Inside `StopLossDelay` the peak is followed, and at the delay's end it restarts at the +//! middle of that moment. +//! - With `UseTakeProfit`, there is no line until the middle has gone `TakeProfit + +//! |TrailingPercent|` past the buy; from then on the line stands no lower than the `TakeProfit` +//! level, and it sells only while the middle is still beyond that level — a middle that fell +//! through it in one tick sells nothing. A short's levels off the buy are divisions. +//! - It fires on the middle crossing the line, at the ticker's arrival; a stop past its own level +//! at the same moment goes first ([`super::Stops`]). +//! +//! The tape carries no book, so the middle is read off the prints the way the book stop reads its +//! BID: the last taker sell stands for the bid, the last taker buy for the ask, sampled on the +//! ticker's clock ([`super::TICKER_PERIOD_MS`]). + +use super::super::ExitParams; +use super::stop_level; +use crate::db::tuner::ticks::{Exit, ExitKind}; +use crate::feed::types::{Side as TickSide, Tick}; + +/// The shortest spacing of two steps of the peak (the core's answer of 2026-09-24). +pub const PEAK_STEP_MS: i64 = 1_000; + +/// The trailing line under `peak`: `TrailingPercent` off it — a multiplication for both sides, +/// mirrored for a short — and no lower than the take profit's level off the buy when +/// `UseTakeProfit` is on. What the walk sells on and what the verdict reads an archived line's +/// jump against. +/// +/// Args: +/// peak: The peak of the spread's middle. +/// buy: The buy the take profit's level counts from. +/// params: The sell parameters: `trailing_pct`, `trailing_take_profit_pct`. +/// long: The trade's side. +pub fn trailing_level(peak: f64, buy: f64, params: &ExitParams, long: bool) -> f64 { + line_of( + peak, + -params.trailing_pct.abs(), + params + .trailing_take_profit_pct + .map(|tp| stop_level(buy, tp, long)), + long, + ) +} + +/// [`trailing_level`] over its parts: `pct` negative, `take` the take profit's level. +fn line_of(peak: f64, pct: f64, take: Option, long: bool) -> f64 { + let line = if long { + peak * (1.0 + pct / 100.0) + } else { + peak * (1.0 - pct / 100.0) + }; + match take { + Some(take) if (long && take > line) || (!long && take < line) => take, + _ => line, + } +} + +/// The trailing stop as the walk runs it. +pub(super) struct Trailing { + long: bool, + /// `TrailingPercent`, negative: the line's distance under the peak. + pct: f64, + /// A step's share of the way from the peak to the middle, `1/(TrailingEMA + 1)`. + weight: f64, + /// The `TakeProfit` level off the buy, when `UseTakeProfit` is on. + take: Option, + /// How far past the buy the middle must go before the line appears, when `UseTakeProfit` is + /// on: `TakeProfit + |TrailingPercent|`. + activation: Option, + /// Whether the line stands — always, without a take profit. + active: bool, + /// The end of `StopLossDelay`: the peak restarts there, and nothing fires before it. + armed_at: i64, + /// The fill: the peak is not followed before it. + fill_ms: i64, + /// Up to when the fact proves the position was not sold (`record::StopAnchor`): the peak is + /// followed, nothing fires. `i64::MIN` when the walk is not the trade's own. + quiet_until: i64, + /// Whether the peak has restarted at the delay's end. + restarted: bool, + peak: Option, + last_step: i64, + bid: Option, + ask: Option, + next_sample: i64, + period_ms: i64, +} + +impl Trailing { + /// The trade's trailing stop under `params`, `None` when `UseTrailing` is off. + /// + /// Args: + /// long: The trade's side. + /// buy: The fill price every level off the buy counts from. + /// armed_at: The end of `StopLossDelay`. + /// fill_ms: The fill. + /// first_ms: The first print the walk will feed; the ticker's clock is run back to it, + /// as the book stop's is. + /// params: The sell parameters. + /// quiet_until: See the field. + pub(super) fn new( + long: bool, + buy: f64, + armed_at: i64, + fill_ms: i64, + first_ms: i64, + params: &ExitParams, + quiet_until: i64, + ) -> Option { + if params.trailing_pct == 0.0 { + return None; + } + let pct = -params.trailing_pct.abs(); + // A level off the buy: a long's product, a short's division (the core's answer 9) — the + // stop's own conversion, boundary included. + let take = params + .trailing_take_profit_pct + .map(|tp| stop_level(buy, tp, long)); + let activation = params + .trailing_take_profit_pct + .map(|tp| stop_level(buy, tp + pct.abs(), long)); + let period_ms = params.model.ticker_period_ms.max(1); + let anchor = fill_ms + period_ms; + let back = (anchor - first_ms).max(0) / period_ms; + Some(Self { + long, + pct, + weight: 1.0 / (params.trailing_ema.max(0.0).round() + 1.0), + take, + activation, + active: take.is_none(), + armed_at, + fill_ms, + quiet_until, + restarted: false, + peak: None, + last_step: i64::MIN, + bid: None, + ask: None, + next_sample: anchor - back * period_ms, + period_ms, + }) + } + + /// Whether `a` is past `b` in the profit direction: above for a long. + fn beyond(&self, a: f64, b: f64) -> bool { + if self.long { a > b } else { a < b } + } + + /// The middle of the spread as the prints so far leave it. + fn middle(&self) -> Option { + match (self.bid, self.ask) { + (Some(bid), Some(ask)) => Some((bid + ask) / 2.0), + (one, other) => one.or(other), + } + } + + /// The line under the peak, never lower than the take profit's level ([`trailing_level`]). + fn line(&self, peak: f64) -> f64 { + line_of(peak, self.pct, self.take, self.long) + } + + /// The ticker's arrivals strictly before `until`, and the first that crossed the line: the + /// trailing stop, at that moment and the middle's price. + pub(super) fn before(&mut self, until: i64) -> Option { + while self.next_sample < until { + let at = self.next_sample; + self.next_sample += self.period_ms; + if at <= self.fill_ms { + continue; + } + let Some(mid) = self.middle() else { + continue; + }; + if at >= self.armed_at && !self.restarted { + self.restarted = true; + self.peak = Some(mid); + self.last_step = at; + } else { + match self.peak { + None => { + self.peak = Some(mid); + self.last_step = at; + } + Some(peak) if self.beyond(mid, peak) && at - self.last_step >= PEAK_STEP_MS => { + self.peak = Some(peak + (mid - peak) * self.weight); + self.last_step = at; + } + Some(_) => {} + } + } + if at < self.armed_at { + continue; + } + if !self.active { + match self.activation { + Some(level) if !self.beyond(level, mid) => self.active = true, + _ => continue, + } + } + let Some(peak) = self.peak else { + continue; + }; + let crossed = !self.beyond(mid, self.line(peak)); + let above_take = self.take.is_none_or(|take| self.beyond(mid, take)); + if crossed && above_take && at > self.quiet_until { + return Some(Exit { + t_ms: at, + price: mid, + kind: ExitKind::Stop, + }); + } + } + None + } + + /// Read a print into the spread proxy: a taker sell prints at the bid, a taker buy at the ask. + pub(super) fn see(&mut self, tick: &Tick) { + let price = f64::from(tick.price); + match tick.side { + TickSide::Sell => self.bid = Some(price), + TickSide::Buy => self.ask = Some(price), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing/tests.rs new file mode 100644 index 00000000..5b86258d --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing/tests.rs @@ -0,0 +1,230 @@ +//! The trailing stop on synthetic tapes. Every tape quotes a spread by a taker sell (the bid) +//! and a taker buy (the ask) at the same moment; the ticker arrives every +//! [`crate::db::tuner::ticks::exit::stops::TICKER_PERIOD_MS`] = 2 150 ms after the fill at 0. + +use super::trailing_level; +use crate::db::tuner::ticks::exit::ExitParams; +use crate::db::tuner::ticks::exit::line::walk; +use crate::db::tuner::ticks::exit::tests::{deal, fill, params}; +use crate::db::tuner::ticks::verify::stated_peak; +use crate::db::tuner::ticks::{EntryParams, ExitKind, verify}; +use crate::feed::types::{Side as TickSide, Tick}; + +fn print(t_ms: i64, price: f64, side: TickSide) -> Tick { + Tick { + time_ms: t_ms as f64, + price: price as f32, + qty: 1.0, + side, + } +} + +/// A spread quoted at `t_ms`: the bid by a taker sell, the ask by a taker buy. +fn quote(t_ms: i64, bid: f64, ask: f64) -> [Tick; 2] { + [ + print(t_ms, bid, TickSide::Sell), + print(t_ms, ask, TickSide::Buy), + ] +} + +fn tape(quotes: &[(i64, f64, f64)]) -> Vec { + quotes + .iter() + .flat_map(|&(t, bid, ask)| quote(t, bid, ask)) + .collect() +} + +/// A 1 % trailing, a take far away, no stop. +fn trailing() -> ExitParams { + ExitParams { + sell_price_pct: 50.0, + trailing_pct: -1.0, + ..params() + } +} + +fn far_take() -> f64 { + 150.0 +} + +/// The middle 100.1 at the first arrival (2 150) starts the peak; 102.1 at the second (4 300) +/// raises it; 101.0 at the third (6 450) is under 102.1 · 0.99 = 101.079 and sells — at the +/// arrival, at the middle. +#[test] +fn the_line_follows_the_peak_of_the_middle_and_sells_on_it() { + let ticks = tape(&[ + (100, 100.0, 100.2), + (3_000, 102.0, 102.2), + (5_000, 100.9, 101.1), + (9_000, 100.0, 100.2), + ]); + let w = walk(&deal(false), &ticks, fill(), far_take(), &trailing()); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 6_450), "{w:?}"); + assert!((w.exit.price - 101.0).abs() < 1e-4, "{w:?}"); +} + +/// `TrailingEMA` 4: a step moves the peak a fifth of the way, 100.1 → 100.5, so the line stays at +/// 99.495 and the same fall does not reach it. +#[test] +fn the_trailing_ema_moves_the_peak_a_share_of_the_way() { + let ticks = tape(&[ + (100, 100.0, 100.2), + (3_000, 102.0, 102.2), + (5_000, 100.9, 101.1), + (9_000, 100.9, 101.1), + ]); + let smoothed = ExitParams { + trailing_ema: 4.0, + ..trailing() + }; + let w = walk(&deal(false), &ticks, fill(), far_take(), &smoothed); + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd, "{w:?}"); +} + +/// `UseTakeProfit` 2 % with a 1 % trailing: no line until the middle passes 103; then the line +/// stands no lower than 102 and sells only while the middle is still above 102. +#[test] +fn the_take_profit_holds_the_line_back_and_floors_the_sale() { + let tp = ExitParams { + trailing_take_profit_pct: Some(2.0), + ..trailing() + }; + // Up to 102.5 and back to 100: the line never appeared. + let short_of_it = tape(&[ + (100, 102.4, 102.6), + (3_000, 99.9, 100.1), + (9_000, 99.9, 100.1), + ]); + let w = walk(&deal(false), &short_of_it, fill(), far_take(), &tp); + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd, "{w:?}"); + // Past 103, then 102.1: under 103.2 · 0.99 = 102.168 and above 102 — sold. + let sold = tape(&[ + (100, 103.1, 103.3), + (3_000, 102.0, 102.2), + (9_000, 102.0, 102.2), + ]); + let w = walk(&deal(false), &sold, fill(), far_take(), &tp); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 4_300), "{w:?}"); + // Past 103, then straight to 101.9 in one arrival: below the take profit, nothing sells. + let through = tape(&[ + (100, 103.1, 103.3), + (3_000, 101.8, 102.0), + (9_000, 101.8, 102.0), + ]); + let w = walk(&deal(false), &through, fill(), far_take(), &tp); + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd, "{w:?}"); +} + +/// The peak followed inside `StopLossDelay` restarts at the middle of the delay's end: a spike +/// to 105 before it does not leave the line at 103.95. +#[test] +fn the_peak_restarts_where_the_delay_ends() { + let delayed = ExitParams { + stop_loss_delay_s: 5.0, + ..trailing() + }; + let ticks = tape(&[ + (100, 104.9, 105.1), + (3_000, 100.9, 101.1), + (7_000, 100.4, 100.6), + (12_000, 100.4, 100.6), + ]); + let w = walk(&deal(false), &ticks, fill(), far_take(), &delayed); + assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd, "{w:?}"); + // Without the delay the spike is the peak, and 101 is under its line. + let w = walk(&deal(false), &ticks, fill(), far_take(), &trailing()); + assert_eq!(w.exit.kind, ExitKind::Stop, "{w:?}"); +} + +/// A short's peak is the lowest middle and its line 1 % above it. +#[test] +fn a_short_trails_the_lowest_middle() { + let ticks = tape(&[ + (100, 99.8, 100.0), + (3_000, 97.8, 98.0), + (5_000, 98.9, 99.1), + (9_000, 98.9, 99.1), + ]); + // 97.9 · 1.01 = 98.879; the middle 99.0 is above it at 6 450. + let w = walk(&deal(true), &ticks, fill(), 50.0, &trailing()); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 6_450), "{w:?}"); +} + +/// The line under a peak, floored at the take profit's level off the buy — a short's by division. +#[test] +fn the_trailing_level_is_the_line_under_the_peak_floored_at_the_take() { + let p = trailing(); + assert!((trailing_level(102.1, 100.0, &p, true) - 101.079).abs() < 1e-9); + assert!((trailing_level(97.9, 100.0, &p, false) - 98.879).abs() < 1e-9); + let tp = ExitParams { + trailing_take_profit_pct: Some(2.0), + ..trailing() + }; + assert!((trailing_level(102.5, 100.0, &tp, true) - 102.0).abs() < 1e-9); + // A short's line 97.5 · 1.01 = 98.475 stands above its take 100/1.02 = 98.04: the take caps it; + // 97.0 · 1.01 = 97.97 is already below the take and stays. + assert!((trailing_level(97.5, 100.0, &tp, false) - 100.0 / 1.02).abs() < 1e-9); + assert!((trailing_level(97.0, 100.0, &tp, false) - 97.97).abs() < 1e-9); +} + +/// A fact the trailing closed: the verdict cuts its archived line at the panic sell — the first +/// move past the fact's own line under the peak its reason printed — and times the model's +/// trailing against that move. Without the cut the panic sell's move would count as a line move +/// the model never made. +#[test] +fn verify_cuts_a_trailing_fact_at_its_panic_sell() { + assert_eq!( + stated_peak( + "TrailingStop AutoActivated on price drop: ASK = 101.10 LastPrice = 101.00; PeakPrice = 102.10; allowed drop" + ), + Some(102.1) + ); + assert_eq!( + stated_peak("TrailingStop AutoActivated … PeakPrice = ;"), + None + ); + let ticks = tape(&[ + (100, 100.0, 100.2), + (3_000, 102.0, 102.2), + (5_000, 100.9, 101.1), + (9_000, 100.0, 100.2), + ]); + let mut d = deal(false); + d.sell_reason = "TrailingStop AutoActivated on price drop: ASK = 101.10 LastPrice = 101.00; \ + PeakPrice = 102.10; allowed drop level: -30.0% ; spread: 0.5%" + .into(); + d.close_ms = 6_900; + d.sell_price = 100.5; + // The take as placed, then the panic sell at 100.5. + let archived = [(0, 150.0), (6_500, 100.5)]; + let v = verify( + &d, + &ticks, + &EntryParams::Fact, + &trailing(), + None, + Some(&archived), + ); + assert_eq!(v.exit_kind, Some(ExitKind::Stop), "{v:?}"); + assert_eq!(v.exit, Some(true), "{v:?}"); +} + +/// A book stop past its level on the same arrival as the trailing line goes first: the exit is +/// the stop's, at the bid the book stop reads, not the trailing's middle. +#[test] +fn the_stop_goes_first_on_the_same_arrival() { + let both = ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: false, + stop_loss_ema: 7.0, + ..trailing() + }; + let ticks = tape(&[ + (100, 100.0, 100.2), + (3_000, 98.0, 98.4), + (9_000, 98.0, 98.4), + ]); + let w = walk(&deal(false), &ticks, fill(), far_take(), &both); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 4_300), "{w:?}"); + assert!((w.exit.price - 98.0).abs() < 1e-4, "the bid, {w:?}"); +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/tests.rs new file mode 100644 index 00000000..36fd6506 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/tests.rs @@ -0,0 +1,70 @@ +//! Fixtures the sell line's section tests share: a flat deal, a fill at 100, a tape of prints. + +use super::ExitParams; +use crate::db::tuner::ticks::{Deal, Deltas, Fill, ModelSettings}; +use crate::feed::types::Side as TickSide; +use crate::feed::types::Tick; + +pub(super) fn tick(t_ms: i64, price: f64) -> Tick { + Tick { + time_ms: t_ms as f64, + price: price as f32, + qty: 1.0, + side: TickSide::Buy, + } +} + +pub(super) fn tape(points: &[(i64, f64)]) -> Vec { + points.iter().map(|&(t, p)| tick(t, p)).collect() +} + +pub(super) fn deal(short: bool) -> Deal { + Deal { + report_uid: 1, + core_uid: 7, + core_name: String::new(), + strategy_id: 42, + kind: "MoonShot".into(), + coin: "ACE".into(), + buy_ms: 0, + close_ms: 60_000, + buy_price: 100.0, + sell_price: 100.5, + spent: 1_000.0, + is_short: short, + sell_reason: "Auto Price Down".into(), + fact_pnl: 5.0, + profit: None, + deltas: Deltas::default(), + tick: None, + pre_spike_ask: None, + archived_take: None, + hook_depth_pct: None, + hook_stated_take_pct: None, + step_lag_ms: 0.0, + stop_anchor: None, + delta_track: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + } +} + +pub(super) fn fill() -> Fill { + Fill { + t_ms: 0, + price: 100.0, + } +} + +/// A 1 % take, no latency, and the rule under test. +pub(super) fn params() -> ExitParams { + ExitParams { + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + ..ExitParams::default() + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/line.rs b/crates/moon-core/src/db/tuner/ticks/line.rs deleted file mode 100644 index 626e48ec..00000000 --- a/crates/moon-core/src/db/tuner/ticks/line.rs +++ /dev/null @@ -1,811 +0,0 @@ -//! The moving sell line: where the sell order stood at every moment after the fill, under the -//! rules of the strategy's "Sell order" and "Stops" tabs, and which print crossed it. -//! -//! From the Moonbot FAQ (`PriceDown*`, `SellLevel*`, `SellShot*`, `StopLoss*` answers) and the -//! live strategies (2026-09-20: 862 of 1 331 run `PriceDownTimer` 1 s with `PriceDownPercent` -//! 50 relative, `SellLevelDelay` is absent everywhere, `IgnoreSellShot` is on all but two): -//! -//! - **PriceDown** — `PriceDownTimer` seconds after the buy the sell is lowered by -//! `PriceDownPercent`: of the distance to the buy when `PriceDownRelative` (`sell −= (sell − -//! buy) · pct`), of the price otherwise (`sell −= buy · pct`); then again every -//! `PriceDownDelay` seconds (0 reads as the terminal's own 0.33 s floor), never below -//! `PriceDownAllowedDrop` per cent over the buy. A zero timer never starts. -//! - **SellLevel** — `SellLevelDelay` seconds after the buy (a negative value is the 0.33 s -//! floor, zero never), the sell moves to the highest print of the last `SellLevelTime` -//! seconds adjusted by `SellLevelAdjust` per cent — of that high, or of its distance to the -//! buy when `SellLevelRelative` — at most `SellLevelCount` times, every `SellLevelDelayNext` -//! (or `SellLevelDelay`) seconds, inside `SellLevelWorkTime`, never below -//! `SellLevelAllowedDrop` per cent over the buy. -//! - **SellShot** (`IgnoreSellShot` off, `SellShotDistance` non-zero) — after `SellShotDelay` -//! the sell keeps `SellShotDistance` per cent off the highest print of the last -//! `SellShotCalcInterval` seconds, re-placed when its distance leaves the corridor -//! `Distance · (1 ± Corridor/100)`: after `SellShotRaiseWait` when moving away from the buy, -//! after `SellShotReplaceDelay` when moving toward it; `SellShotPriceDown` narrows the -//! distance by that much per second past `SellShotPriceDownDelay`; the line stays between -//! `SellShotAllowedDown` and `SellShotAllowedUp` per cent over the buy. -//! - **PumpMove** (PumpsDetection's own tab; `PumpMoveTimer` non-zero) — once, `PumpMoveTimer` -//! seconds after the take is placed, the sell moves to `PumpMovePersent` per cent of the way -//! from the pump's peak back to the buy (FAQ: "учитывается процент между пиковой ценой и ценой -//! покупки"), the peak read over [`PUMP_PEAK_LOOKBACK_MS`] before the take up to the move. -//! - **StopLoss** — `StopLoss` per cent from the buy (negative: a loss), armed -//! `StopLossDelay` seconds after the buy. With `FastStopLoss` the first print through it is -//! a market exit at the print's own price. Without it — the core's default — the core -//! watches the REST ticker's BID (a short's ASK), a long's averaged over `StopLossEMA` of the -//! ticker's arrivals, and the walk reads a proxy of that: the last print on that side of the -//! book (a taker sell prints at the BID), sampled every [`TICKER_PERIOD_MS`], averaged the -//! same way from before the fill on; at `StopLossEMA` 0 the core's price series fires it too -//! ([`SERIES_TICK_MS`]). The exit is at the sample, at the proxy's price. Where the core's -//! panic sell then fills is the book's business — see [`super::verify`] for how the fact is -//! judged. -//! -//! Every rule is written for a long and mirrored for a short by [`Side`]. A replacement reaches -//! the exchange `latency_ms` later, as the entry's does: a spike through the OLD level in that -//! gap fills there. The line's replacements are recorded so the model can be held against the -//! archived Exit line of the trade. -//! -//! What goes to the exchange is rounded to the nearest step of the market's price grid (a sell -//! limit is placed on the grid), and the rules carry on from the ORDER's price once a move -//! reached the book — from the computed value while rounding kept the order where it was -//! ([`advance`]). The rounding is what decides a print AT the level: on ARX (2026-09-21) the -//! `PriceDownAllowedDrop` floor computed to 0.196445, the core's order stood at 0.1964, and -//! the tape's high was exactly 0.1964 — the unrounded line was never reached. - -use super::exit::{ExitParams, stop_pct}; -use super::mshot::FAST_ALGO_WINDOW_MS; -use super::{Deal, Exit, ExitKind, Fill, reaches, round_to_step}; -use crate::feed::types::Tick; - -/// The terminal's own floor on a step delay of zero: the FAQ's "0.33 s internal minimum". -pub const STEP_FLOOR_MS: i64 = 330; - -/// How often the core's REST ticker brings the BID (a short's ASK) the non-fast stop watches, -/// and so how often the walk samples its proxy. The core developer (2026-09-23): the stop reads -/// the ticker, not the book and not the trades; the ticker arrives every ~2–2.3 s (Gate's spot -/// half a second slower, about once a second around each hour's turn); the stop is checked -/// every ~0.1 s but the price only moves with the ticker, so it fires on the first arrival -/// that puts it past the level. The middle of that range: over the 192 live book-watching -/// stops of 2026-09-23, 2 000–2 300 ms move the stops judged on time by ±5, the noise of a -/// phase no record keeps. -pub const TICKER_PERIOD_MS: i64 = 2_150; - -/// The core's price-series tick: one point per 250 ms, of the prints the tick brought the one -/// closest to the previous point (`docs-internal/STRATEGY_FORMULAS/deltas.md` §7). A stop at -/// `StopLossEMA` 0 fires on that series as well as on the ticker's price (the core developer, -/// 2026-09-23) — a lone print in its tick is a point, a spike among prints near the last -/// point is not. -pub const SERIES_TICK_MS: i64 = 250; - -/// How far past `PumpMoveTimer` the core's pump move lands, less the model's own placement -/// latency: over 32 archived PumpsDetection lines (2026-09-22, every live Pump strategy runs -/// `PumpMoveTimer` 2 with `PumpMovePersent` 1) the move came 575–704 ms past the timer, a -/// median of 610 ms — counted from the take, not from the buy's report stamp: one take placed -/// 32 s after `buydatems` still moved 2.6 s after itself. -pub const PUMP_MOVE_LAG_MS: i64 = 500; - -/// How far before the take the pump's peak is looked for. The FAQ counts the peak from the -/// detect, which the report does not stamp on older rows, and the peak itself is the print that -/// triggered it — a median 70 ms before the buy, up to 6 s when the buy order waited for the -/// retrace. Over the same 32 lines a window from 10 s before the take reproduces the moved level -/// on 31; from the buy it reproduces 1, from 4 s before the take 27. -pub const PUMP_PEAK_LOOKBACK_MS: i64 = 10_000; - -/// Which way the position profits, folding every "above/below the buy" into one sign. -#[derive(Clone, Copy)] -struct Side { - long: bool, -} - -impl Side { - /// `pct` per cent over the buy in the PROFIT direction: above for a long, below for a - /// short. - fn over(self, base: f64, pct: f64) -> f64 { - if self.long { - base * (1.0 + pct / 100.0) - } else { - base * (1.0 - pct / 100.0) - } - } - - /// The take side of two levels — the higher for a long — i.e. farther in profit. - fn farther(self, a: f64, b: f64) -> f64 { - if self.long { a.max(b) } else { a.min(b) } - } - - /// The nearer of two levels in profit terms. - fn nearer(self, a: f64, b: f64) -> f64 { - if self.long { a.min(b) } else { a.max(b) } - } - - /// The extreme print in the profit direction over a run. - fn extreme(self, prices: impl Iterator) -> Option { - if self.long { - prices.reduce(f64::max) - } else { - prices.reduce(f64::min) - } - } - - /// Distance of `level` from `reference`, per cent, positive in the profit direction. - fn distance_pct(self, reference: f64, level: f64) -> f64 { - if reference <= 0.0 { - return 0.0; - } - let signed = if self.long { - level - reference - } else { - reference - level - }; - signed / reference * 100.0 - } -} - -/// The weight of a new ticker price in the average the non-fast stop watches, when the core -/// keeps one: `avg = (avg·(N − 1) + bid) / N`, a weight of `1/N`, for a LONG at `StopLossEMA` -/// 3, 5 or 10 only. Any other value — 7 included — and every short watch the bare price (the -/// core developer, 2026-09-23). The FAQ's "average over the last 3, 5, 10 ticks" read as an -/// EMA of `2/(N + 1)` forgot the price before a dump twice as fast, and fired early. -fn stop_average_weight(params: &ExitParams, long: bool) -> Option { - let n = params.stop_loss_ema; - let whole = (n - n.round()).abs() < 1e-9; - (long && whole && matches!(n.round() as i64, 3 | 5 | 10)).then(|| 1.0 / n) -} - -/// The book-watching stop's state: a ticker-price proxy — the last print on the stop's side of -/// the book — sampled every [`TICKER_PERIOD_MS`] and, for a long at `StopLossEMA` 3, 5 or 10, -/// averaged ([`stop_average_weight`]); at `StopLossEMA` 0 the core's price series as well. -/// -/// The core keeps the average for every market from its start, so at the fill it is warm: -/// after a dump it still remembers the prices above, and fires later than a fresh one. The -/// walk feeds it the prints before the fill for that — they move the average and can never -/// fire it. -struct BookStop { - long: bool, - level: f64, - /// The end of `StopLossDelay`: a sample before it is averaged but cannot fire. - armed_at: i64, - /// The fill: a sample or a series point up to it only warms the state. - fill_ms: i64, - /// The average's weight, `None` for the bare price. - weight: Option, - proxy: Option, - avg: Option, - next_sample: i64, - /// The ticker's period (`ModelSettings::ticker_period_ms`), at least a millisecond. - period_ms: i64, - /// Up to when the fact proves this stop did not fire (`record::StopAnchor`): a sample by - /// then is averaged but cannot fire. `i64::MIN` when the walk is not the trade's own stop. - quiet_until: i64, - /// The price series a stop at `StopLossEMA` 0 also fires on. - series: Option, -} - -/// The core's price series as the stop reads it (see [`SERIES_TICK_MS`]). -struct SeriesPoint { - /// The series' last point. - point: Option, - /// Of the prints the open tick brought, the one closest to `point`. - candidate: Option, - /// When the open tick closes; every pending print is before it. - tick_end: i64, - /// The tick's length (`ModelSettings::series_tick_ms`), at least a millisecond. - tick_ms: i64, -} - -impl SeriesPoint { - fn new(first_ms: i64, tick_ms: i64) -> Self { - Self { - point: None, - candidate: None, - tick_end: next_series_tick(first_ms, tick_ms), - tick_ms, - } - } - - /// Read a print into the open tick. - fn see(&mut self, price: f64) { - self.candidate = match (self.point, self.candidate) { - (Some(point), Some(held)) if (held - point).abs() <= (price - point).abs() => { - Some(held) - } - // Before the first point any print will do; the latest stands. - _ => Some(price), - }; - } -} - -/// The end of the series tick a print at `t_ms` falls in: the ticks run on the clock's -/// `tick_ms` boundaries ([`SERIES_TICK_MS`] by default), and a print ON a boundary opens the next -/// tick. -fn next_series_tick(t_ms: i64, tick_ms: i64) -> i64 { - let tick_ms = tick_ms.max(1); - (t_ms.div_euclid(tick_ms) + 1) * tick_ms -} - -impl BookStop { - /// Args: - /// long: The trade's side. - /// level: The stop level. - /// armed_at: The end of `StopLossDelay`. - /// fill_ms: The fill. - /// first_ms: The first print the walk will feed, before the fill when the tape reaches - /// back: the ticker's clock is run back to it so the average is warm at the fill. - /// params: The sell parameters, for `StopLossEMA`. - /// quiet_until: See the field. - fn new( - long: bool, - level: f64, - armed_at: i64, - fill_ms: i64, - first_ms: i64, - params: &ExitParams, - quiet_until: i64, - ) -> Self { - // One period past the fill, and back from there in whole periods: the ticker's phase - // is on no record, so the fill anchors it, however far back the tape reaches. - let period_ms = params.model.ticker_period_ms.max(1); - let anchor = fill_ms + period_ms; - let back = (anchor - first_ms).max(0) / period_ms; - Self { - long, - level, - armed_at, - fill_ms, - weight: stop_average_weight(params, long), - proxy: None, - avg: None, - next_sample: anchor - back * period_ms, - period_ms, - quiet_until, - series: (params.stop_loss_ema.abs() < 1e-9) - .then(|| SeriesPoint::new(first_ms, params.model.series_tick_ms)), - } - } - - fn may_fire(&self, at: i64) -> bool { - at > self.fill_ms && at >= self.armed_at && at > self.quiet_until - } - - /// Everything due before the print at `until` — the ticker's arrivals strictly before it, - /// the series ticks closing at or before it — and the earliest that put the stop past its - /// level: the stop, at that moment and that price. - fn before(&mut self, until: i64) -> Option { - let by_ticker = self.samples_before(until); - let by_series = self.series_before(until); - match (by_ticker, by_series) { - (Some(t), Some(s)) => Some(if s.t_ms < t.t_ms { s } else { t }), - (t, s) => t.or(s), - } - } - - /// The ticker's arrivals strictly before `until` — the prints before it are all the proxy - /// has seen — and the first whose price (averaged, when the core averages) is past the - /// level. - fn samples_before(&mut self, until: i64) -> Option { - while self.next_sample < until { - let at = self.next_sample; - self.next_sample += self.period_ms; - let Some(bid) = self.proxy else { - continue; - }; - let avg = match (self.weight, self.avg) { - (Some(w), Some(avg)) => w * bid + (1.0 - w) * avg, - _ => bid, - }; - self.avg = Some(avg); - if self.may_fire(at) && reaches(avg, self.level, self.long) { - return Some(Exit { - t_ms: at, - price: bid, - kind: ExitKind::Stop, - }); - } - } - None - } - - /// The series tick the pending prints fall in, when it closes by `until`: its point, and - /// the stop when that point is strictly past the level. The ticks after it up to `until` - /// brought no print and add no point. - fn series_before(&mut self, until: i64) -> Option { - let series = self.series.as_mut()?; - if series.tick_end > until { - return None; - } - let at = series.tick_end; - series.tick_end = next_series_tick(until, series.tick_ms); - let point = series.candidate.take()?; - series.point = Some(point); - let past = if self.long { - point < self.level - } else { - point > self.level - }; - (past && self.may_fire(at)).then_some(Exit { - t_ms: at, - price: point, - kind: ExitKind::Stop, - }) - } - - /// Read a print: into the series, and into the proxy when it hit the stop's side — a taker - /// sell prints at the BID, a long's stop side; a short's stop watches the ASK, where a - /// taker buy prints. - fn see(&mut self, tick: &Tick) { - let price = f64::from(tick.price); - if let Some(series) = self.series.as_mut() { - series.see(price); - } - let stop_side = if self.long { - crate::feed::types::Side::Sell - } else { - crate::feed::types::Side::Buy - }; - if tick.side == stop_side { - self.proxy = Some(price); - } - } -} - -/// One replacement of the line, for the comparison with the archived Exit points. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct LinePoint { - pub t_ms: i64, - pub price: f64, -} - -/// The walk's answer: the exit and every level the line stood at, placement first. -#[derive(Clone, Debug, PartialEq)] -pub struct LineWalk { - pub exit: Exit, - pub points: Vec, -} - -/// One step of the core's sell price: `level` is what a rule computed, `order` that level on -/// the price grid. When the order lands on a new price, the move goes to the book and the core -/// carries on from the ORDER's price; when rounding keeps it where it was, nothing is sent and -/// the core carries on from the computed value, so the next step can still cross a grid line. -/// Answers whether the order moved. -/// -/// Read off the archived Exit lines (2026-09-22): every step of INDEX's twelve (Gate, relative -/// PriceDown 10 %) lands only when chained off the placed price — off the exact value the second -/// already rounds a step short — and FATCOIN's one-step-per-tick lines climb past a step that -/// rounds back onto the order only when that step's exact value carries into the next. Over -/// 1 421 archived PriceDown lines the rule reproduces every level of 997, against 647 for the -/// exact chain and 949 for the placed price alone. -/// -/// Args: -/// core: The core's sell price, advanced in place. -/// last_sent: The order's price as last sent, advanced when the order moves. -/// level: The level a rule computed. -/// order: `level` on the price grid. -fn advance(core: &mut f64, last_sent: &mut f64, level: f64, order: f64) -> bool { - if (order - *last_sent).abs() <= f64::EPSILON * last_sent.abs() { - *core = level; - return false; - } - *core = order; - *last_sent = order; - true -} - -/// Seconds to milliseconds, with the terminal's floor (`ModelSettings::step_floor_ms`, -/// [`STEP_FLOOR_MS`] by default) for a zero delay. -pub(super) fn step_ms(seconds: f64, floor_ms: i64) -> i64 { - let ms = (seconds * 1000.0) as i64; - if ms <= 0 { floor_ms } else { ms } -} - -/// Walk the tape after the fill under `params`, starting from the take `take`. -/// -/// Args: -/// deal: The row — its side. -/// ticks: The window's prints, ascending. -/// fill: The entry. -/// take: The take level the line starts at (see `ExitModel::take_level`). -/// params: The sell-line rules. -pub fn walk(deal: &Deal, ticks: &[Tick], fill: Fill, take: f64, params: &ExitParams) -> LineWalk { - walk_held(deal, ticks, fill, take, params, None) -} - -/// [`walk`] with the sell HELD — no print fills it — until `hold_until_ms`: the rules keep -/// moving the line, so its level at that moment is known whatever print the model would have -/// sold on before it. The stop is not held: it is a market order and fires as it does. The -/// verdict on a fact reads the line this way, at the close. -/// -/// Args: -/// hold_until_ms: `None` walks as [`walk`] does. -pub fn walk_held( - deal: &Deal, - ticks: &[Tick], - fill: Fill, - take: f64, - params: &ExitParams, - hold_until_ms: Option, -) -> LineWalk { - let side = Side { - long: deal.is_long(), - }; - let latency_ms = params.model.latency_whole_ms(); - let floor_ms = params.model.step_floor_ms; - let armed_at = fill.t_ms + params.sell_delay_ms.max(0.0) as i64; - // When the take is on the book: placed at `armed_at`, there after the same latency as any - // move of the line. - let take_live_at = armed_at + latency_ms; - // What the exchange is given: the level on the price grid. - let placed = |level: f64| match deal.tick { - Some(tick) => round_to_step(level, tick), - None => level, - }; - let take_placed = placed(take); - let mut points = vec![LinePoint { - t_ms: armed_at, - price: take_placed, - }]; - // The exchange's level (what fills, on the grid) and the core's (what the rules move from); - // a move the exchange has not seen yet is `pending`. - let mut exch_line = take_placed; - // Whether a move has reached the exchange: what tells a fill at the take from a fill at - // a level a rule moved the line to — not the price, which a moved line can round back onto. - let mut exch_moved = false; - // The core's sell price: the ORDER's price once a move reached the book, the computed value - // while rounding kept the order where it was — see [`advance`]. The take is an order too. - let mut core_line = take_placed; - // The last level sent to the book, pending or not: what a new level must differ from to - // be a move at all. - let mut last_sent = take_placed; - let mut pending: Option<(i64, f64)> = None; - // Answers whether the order moved — a replace went to the book. - let mut place = |t_ms: i64, level: f64, core: &mut f64, pending: &mut Option<(i64, f64)>| { - if (level - *core).abs() <= f64::EPSILON * core.abs() { - return false; - } - let order = placed(level); - if !advance(core, &mut last_sent, level, order) { - return false; - } - *pending = Some((t_ms + latency_ms, order)); - points.push(LinePoint { - t_ms: t_ms + latency_ms, - price: order, - }); - true - }; - - // --- PriceDown --- - let pd_on = params.price_down_timer_s > 0.0 && params.price_down_pct > 0.0; - let mut pd_next = if pd_on { - Some(fill.t_ms + (params.price_down_timer_s * 1000.0) as i64) - } else { - None - }; - let pd_floor = side.over(fill.price, params.price_down_allowed_drop_pct); - - // --- PumpMove --- one move, timed off the take (see `PUMP_MOVE_LAG_MS`). - let mut pm_next = (params.pump_move_timer_s > 0.0).then(|| { - armed_at + (params.pump_move_timer_s * 1000.0) as i64 + params.model.pump_move_lag_ms - }); - - // --- SellLevel --- - let sl_on = params.sell_level_delay_s != 0.0 - && params.sell_level_time_s > 0.0 - && params.sell_level_count > 0; - let sl_first_ms = if params.sell_level_delay_s < 0.0 { - floor_ms - } else { - (params.sell_level_delay_s * 1000.0) as i64 - }; - let mut sl_next = if sl_on { - Some(fill.t_ms + sl_first_ms) - } else { - None - }; - let sl_step_ms = if params.sell_level_delay_next_s > 0.0 { - (params.sell_level_delay_next_s * 1000.0) as i64 - } else if params.sell_level_delay_next_s < 0.0 { - floor_ms - } else { - sl_first_ms.max(floor_ms) - }; - let mut sl_left = params.sell_level_count; - let sl_until = if params.sell_level_work_time_s > 0.0 { - Some(fill.t_ms + (params.sell_level_work_time_s * 1000.0) as i64) - } else { - None - }; - let sl_floor = side.over(fill.price, params.sell_level_allowed_drop_pct); - - // --- SellShot --- - let ss_on = !params.ignore_sell_shot && params.sell_shot_distance_pct != 0.0; - let ss_from = fill.t_ms + (params.sell_shot_delay_s.max(0.0) * 1000.0) as i64; - // The core's own floor on the SellShot calculation window — the same 100 ms its fast - // algorithm reads, but a rule of the sell, not the entry's re-place window - // (`ModelSettings::replace_window_ms`), and not a setting of the model. - let ss_calc_ms = - ((params.sell_shot_calc_interval_s.max(0.0) * 1000.0) as i64).max(FAST_ALGO_WINDOW_MS); - let ss_low = side.over(fill.price, params.sell_shot_allowed_down_pct); - let ss_high = side.over(fill.price, params.sell_shot_allowed_up_pct); - // `(kind, since)`: which way the line is out of the corridor and since when. - let mut ss_breach: Option<(bool, i64)> = None; - - // --- StopLoss --- - // The ADJUSTED distance decides both whether there is a stop and where it stands — - // reading the raw `stop_loss_pct` for the first and the adjusted one for the second would - // arm a stop the adjustment had cancelled, at the fill price itself, where the next print - // fires it. `over` mirrors the sign for a short, so only the distance is adjusted here; the - // level is NOT snapped to the price grid, unlike every level that reaches the exchange — - // a stop is the core's own trigger for a market sell, and nothing about it is ever placed. - let stop = stop_pct(params, deal, fill.t_ms); - let stop_on = stop != 0.0; - let stop_level = side.over(fill.price, stop); - let stop_from = fill.t_ms + (params.stop_loss_delay_s.max(0.0) * 1000.0) as i64; - // What the fact proves about the stop when this walk runs the trade's own - // (`record::StopAnchor`): it fired when the core's did, at the price the core sold at, and - // not a moment before — nor before the close, on a trade it never stopped. The book the - // stop watches is not on the tape; the fact is the book's own answer. - let anchor = deal.stop_anchor.filter(|a| a.holds(deal, fill, params)); - let quiet_until = anchor.map_or(i64::MIN, |a| a.quiet_until_ms); - let fired = anchor.and_then(|a| a.fired); - // The non-fast stop's ticker proxy: the last print on the stop's side of the book, sampled - // on the ticker's clock, averaged as the core averages (see `BookStop`) — warmed on the - // prints before the fill, which can never fire it. - let book_stop = stop_on && !params.fast_stop_loss; - let mut book = book_stop.then(|| { - let first_ms = ticks - .first() - .map_or(fill.t_ms, |t| (t.time_ms as i64).min(fill.t_ms)); - let mut book = BookStop::new( - side.long, - stop_level, - stop_from, - fill.t_ms, - first_ms, - params, - quiet_until, - ); - for tick in ticks - .iter() - .take_while(|t| (t.time_ms as i64) <= fill.t_ms) - .filter(|t| t.price.is_finite() && t.price > 0.0) - { - let _warm_only = book.before(tick.time_ms as i64); - book.see(tick); - } - book - }); - let anchored_stop = |at: i64, price: f64, points: Vec| LineWalk { - exit: Exit { - t_ms: at, - price, - kind: ExitKind::Stop, - }, - points, - }; - - let mut last_t = fill.t_ms; - for (index, tick) in ticks.iter().enumerate() { - let t_ms = tick.time_ms as i64; - let price = f64::from(tick.price); - if t_ms <= fill.t_ms || !price.is_finite() || price <= 0.0 { - continue; - } - last_t = t_ms; - // The fact's own stop fired between the last print and this one: ahead of anything - // this print does, and after everything the prints before it did. - if let Some((at, sold)) = fired.filter(|(at, _)| t_ms >= *at) { - return anchored_stop(at, sold, points); - } - // The timer-driven rules moved the line at their own moments, between prints; every - // step due by this print happened BEFORE it, and a step that also reached the book - // before it is what this print meets. - // - // PriceDown steps, one per due moment, and the pump move, in the order they fell due: - // each step chains off where the one before it left the line. - loop { - let pd_due = pd_next.filter(|due| t_ms >= *due); - let pm_due = pm_next.filter(|due| t_ms >= *due); - if let Some(due) = pm_due.filter(|pm| pd_due.is_none_or(|pd| *pm <= pd)) { - pm_next = None; - let from = armed_at - params.model.pump_peak_lookback_ms; - let peak = side.extreme( - ticks[..=index] - .iter() - .filter(|t| { - let tt = t.time_ms as i64; - tt >= from && tt <= due && t.price > 0.0 - }) - .map(|t| f64::from(t.price)), - ); - if let Some(peak) = peak { - let next = peak + (fill.price - peak) * params.pump_move_pct / 100.0; - place(due, next, &mut core_line, &mut pending); - } - continue; - } - let Some(due) = pd_due else { - break; - }; - let next = if params.price_down_relative { - core_line - (core_line - fill.price) * params.price_down_pct / 100.0 - } else { - core_line - side.over(fill.price, params.price_down_pct) + fill.price - }; - let next = side.farther(next, pd_floor); - if (next - core_line).abs() <= f64::EPSILON * core_line.abs() { - pd_next = None; - continue; - } - // The core times the next step from this one's going through (`Deal::step_lag_ms`); - // a step rounding kept in place sent nothing and waits for nothing — over the - // archived GateF lines two delays with such a step between them run 32 ms over, - // against 47 ms for one real step. - let moved = place(due, next, &mut core_line, &mut pending); - let lag_ms = if moved { - deal.step_lag_ms.max(0.0) as i64 - } else { - 0 - }; - pd_next = Some(due + step_ms(params.price_down_delay_s, floor_ms) + lag_ms); - } - // SellLevel: to the high of the look-back, adjusted. - while let Some(due) = sl_next.filter(|due| t_ms >= *due) { - if sl_left == 0 || sl_until.is_some_and(|until| due > until) { - sl_next = None; - break; - } - let from = due - (params.sell_level_time_s * 1000.0) as i64; - let high = side.extreme( - ticks[..=index] - .iter() - .filter(|t| { - let tt = t.time_ms as i64; - tt >= from && tt <= due && t.price > 0.0 - }) - .map(|t| f64::from(t.price)), - ); - if let Some(high) = high { - let next = if params.sell_level_relative { - fill.price + (high - fill.price) * params.sell_level_adjust_pct / 100.0 - } else { - side.over(high, params.sell_level_adjust_pct) - }; - let next = side.farther(next, sl_floor); - place(due, next, &mut core_line, &mut pending); - } - sl_left -= 1; - sl_next = Some(due + sl_step_ms); - } - if let Some((_, level)) = pending.filter(|(apply_at, _)| t_ms >= *apply_at) { - exch_line = level; - exch_moved = true; - pending = None; - } - // The book-watching stop samples between prints: every sample due BEFORE this print - // reads the proxy the earlier prints left, every series tick closing by it the points - // they left, and one past the level fires at its own moment, ahead of anything this - // print does. - if let Some(book) = book.as_mut() { - if let Some(exit) = book.before(t_ms) { - return LineWalk { exit, points }; - } - book.see(tick); - } - // The fast stop is a market order the core fires on the print; the sell is a limit the - // print reaches. Both come before the print-driven rule below moves anything. - if stop_on - && !book_stop - && t_ms >= stop_from - && t_ms > quiet_until - && reaches(price, stop_level, side.long) - { - return LineWalk { - exit: Exit { - t_ms, - price, - kind: ExitKind::Stop, - }, - points, - }; - } - // A print AT the level fills the sell — the optimistic reading the spec states (§7: - // the queue standing at the level is not modelled; COOL 2026-09-21 printed 31 - // contracts at the level against a sell of 18 000 and the core's line stood). The - // verdict on the fact does not lean on this: `verify` judges the line by where it - // STOOD at the close, not by which print the model sold on. - // - // The take is an order like every move, and reaches the book `latency_ms` after the - // core placed it: the spike's own tail, printed in the milliseconds after the fill, is - // not a print the take was there for. Filling on it turned 30 of 88 stopped MoonShot - // trades into wins on the live sample (2026-09-23), the take "touched" 9 ms after the - // buy by the pump it was bought on. - let held = hold_until_ms.is_some_and(|until| t_ms <= until); - if t_ms > armed_at && t_ms >= take_live_at && !held && reaches(price, exch_line, !side.long) - { - return LineWalk { - exit: Exit { - t_ms, - price: exch_line, - // What the print met: the take as placed, or a level a rule moved it to. - kind: if !exch_moved { - ExitKind::Take - } else { - ExitKind::Line - }, - }, - points, - }; - } - // SellShot: the line follows the market inside its corridor — driven by this print. - if ss_on && t_ms >= ss_from { - let from = t_ms - ss_calc_ms; - let reference = side.extreme( - ticks[..=index] - .iter() - .filter(|t| (t.time_ms as i64) >= from && t.price > 0.0) - .map(|t| f64::from(t.price)), - ); - if let Some(reference) = reference { - let elapsed_s = (t_ms - fill.t_ms) as f64 / 1000.0; - let mut distance = params.sell_shot_distance_pct; - if params.sell_shot_price_down < 0.0 { - let past = (elapsed_s - params.sell_shot_price_down_delay_s).max(0.0); - distance -= params.sell_shot_price_down.abs() * past; - } - let corridor = distance.abs() * params.sell_shot_corridor_pct / 100.0; - let d = side.distance_pct(reference, core_line); - let out = if d > distance + corridor { - Some(false) // too far from the market: move toward the buy - } else if d < distance - corridor { - Some(true) // too close: move away from the buy - } else { - None - }; - match out { - None => ss_breach = None, - Some(away) => { - let since = match ss_breach { - Some((seen, since)) if seen == away => since, - _ => { - ss_breach = Some((away, t_ms)); - t_ms - } - }; - let wait_ms = if away { - (params.sell_shot_raise_wait_s * 1000.0) as i64 - } else { - (params.sell_shot_replace_delay_s * 1000.0) as i64 - }; - if t_ms - since >= wait_ms { - let next = side.over(reference, distance); - let next = side.nearer(side.farther(next, ss_low), ss_high); - place(t_ms, next, &mut core_line, &mut pending); - ss_breach = None; - } - } - } - } - } - } - let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(last_t); - // The fact's own stop, past the last print: the tape went quiet, the core did not. - if let Some((at, sold)) = fired { - return anchored_stop(at, sold, points); - } - // The book stop's samples up to the tape's end — the one AT the last print included — read - // the proxy the last prints left; the loop only ever reaches the samples before a print. - if let Some(exit) = book.as_mut().and_then(|book| book.before(tail + 1)) { - return LineWalk { exit, points }; - } - // Nothing closed it inside the tape. Not the report's own exit: a variant that never - // closes is not a trade, whatever the core's rules did, and the caption counts it. - LineWalk { - exit: Exit { - t_ms: tail, - price: f64::NAN, - kind: ExitKind::OpenAtWindowEnd, - }, - points, - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/line/tests.rs b/crates/moon-core/src/db/tuner/ticks/line/tests.rs deleted file mode 100644 index 225934bb..00000000 --- a/crates/moon-core/src/db/tuner/ticks/line/tests.rs +++ /dev/null @@ -1,920 +0,0 @@ -//! The sell line's rules on synthetic tapes: one rule at a time, then the mirror. - -use super::*; -use crate::db::tuner::ticks::exit::{ExitModel, archived_pre_spike_ask}; -use crate::db::tuner::ticks::{Deltas, EntryParams, ModelSettings, verify}; -use crate::feed::types::Side as TickSide; - -fn tick(t_ms: i64, price: f64) -> Tick { - Tick { - time_ms: t_ms as f64, - price: price as f32, - qty: 1.0, - side: TickSide::Buy, - } -} - -fn tape(points: &[(i64, f64)]) -> Vec { - points.iter().map(|&(t, p)| tick(t, p)).collect() -} - -fn deal(short: bool) -> Deal { - Deal { - report_uid: 1, - core_uid: 7, - core_name: String::new(), - strategy_id: 42, - kind: "MoonShot".into(), - coin: "ACE".into(), - buy_ms: 0, - close_ms: 60_000, - buy_price: 100.0, - sell_price: 100.5, - spent: 1_000.0, - is_short: short, - sell_reason: "Auto Price Down".into(), - fact_pnl: 5.0, - profit: None, - deltas: Deltas::default(), - tick: None, - pre_spike_ask: None, - archived_take: None, - hook_depth_pct: None, - hook_stated_take_pct: None, - step_lag_ms: 0.0, - stop_anchor: None, - delta_track: None, - own_entry: None, - buy_set_ms: None, - corridor: None, - entry_placed: None, - } -} - -fn fill() -> Fill { - Fill { - t_ms: 0, - price: 100.0, - } -} - -/// A 1 % take, no latency, and the rule under test. -fn params() -> ExitParams { - ExitParams { - model: ModelSettings { - latency_ms: 0.0, - ..ModelSettings::default() - }, - ..ExitParams::default() - } -} - -// ---- the take off the archive ---------------------------------------------------------------- - -/// `MShotSellAtLastPrice` reads the book's ask, which the tape has not; the archive's first -/// Exit point gives it back with the trade's own adjustment divided out, and a caller that -/// recovered it gets a take the tape alone would have put 0.5 % lower. -#[test] -fn the_archived_ask_sets_the_take_where_the_core_placed_it() { - let p = ExitParams { - sell_price_pct: 1.5, - sell_at_last_price: true, - sell_price_adjust_pct: 0.2, - ..params() - }; - // GSTOCKBSC, 2026-09-21: the take as placed, 0.031603, is the ask 0.0316663 less 0.2 %. - let ask = archived_pre_spike_ask(Some(&[(0, 0.031603), (1_266, 0.030910)]), &p, false) - .expect("the rule was on"); - assert!((ask - 0.031603 / 0.998).abs() < 1e-12); - let mut d = deal(false); - d.buy_price = 0.029292; - d.pre_spike_ask = Some(ask); - let f = Fill { - t_ms: 0, - price: 0.029292, - }; - // The tape's own pre-spike print sits 0.5 % under the ask. - let ticks = tape(&[(-5_000, 0.031506), (1_000, 0.0300)]); - let take = ExitModel::new(&p).take_level(&d, &ticks, f); - assert!((take - 0.031603).abs() < 1e-9, "{take}"); - d.pre_spike_ask = None; - let from_tape = ExitModel::new(&p).take_level(&d, &ticks, f); - assert!((from_tape - 0.031506 * 0.998).abs() < 1e-7, "{from_tape}"); - // With the rule off the archive says nothing about the ask. - let off = ExitParams { - sell_at_last_price: false, - ..p.clone() - }; - assert_eq!( - archived_pre_spike_ask(Some(&[(0, 0.031603)]), &off, false), - None - ); - assert_eq!(archived_pre_spike_ask(None, &p, false), None); - // A short's take is the ask adjusted UP toward the entry, `ask / (1 − 0.2 %)`: the ask is - // the take times 0.998. - let short_ask = archived_pre_spike_ask(Some(&[(0, 0.031603)]), &p, true).expect("on"); - assert!((short_ask - 0.031603 * 0.998).abs() < 1e-12); -} - -/// A short MoonShot's take is divided off the fill, `fill / (1 + SellPrice/100)`, and the ask's -/// branch placed at `ask / (1 − adjust/100)` when it is the lower (the core developer, -/// 2026-09-23; ONE and BCH_RP on the archive). -#[test] -fn a_short_moonshot_take_divides_off_the_fill() { - let p = ExitParams { - sell_price_pct: 1.0, - ..params() - }; - let take = ExitModel::new(&p).take_level(&deal(true), &[], fill()); - assert!((take - 100.0 / 1.01).abs() < 1e-9, "{take}"); - let lifted = ExitParams { - sell_at_last_price: true, - sell_price_adjust_pct: 1.0, - ..p - }; - let mut d = deal(true); - d.pre_spike_ask = Some(97.0); - let take = ExitModel::new(&lifted).take_level(&d, &[], fill()); - assert!((take - 97.0 / 0.99).abs() < 1e-9, "{take}"); - // A MoonHook short keeps the product: its stored take cannot tell the two apart. - let mut hook = deal(true); - hook.kind = "MoonHook".into(); - let take = ExitModel::new(&p).take_level(&hook, &[], fill()); - assert!((take - 99.0).abs() < 1e-9, "{take}"); -} - -// ---- PriceDown ------------------------------------------------------------------------------- - -#[test] -fn price_down_steps_the_line_toward_the_buy_on_the_timer() { - // Take 101. Timer 1 s, then every 1 s, 50 % of the remaining distance (relative): 100.5 - // at t=1000, 100.25 at t=2000, floored at +0.1 % = 100.1. - let p = ExitParams { - price_down_timer_s: 1.0, - price_down_pct: 50.0, - price_down_delay_s: 1.0, - price_down_relative: true, - price_down_allowed_drop_pct: 0.1, - ..params() - }; - let ticks = tape(&[ - (500, 100.0), - (1_500, 100.0), - (2_500, 100.0), - (3_500, 100.0), - (4_500, 100.0), - (5_000, 100.2), - ]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - let levels: Vec = w.points.iter().map(|pt| pt.price).collect(); - assert!((levels[0] - 101.0).abs() < 1e-9); - assert!((levels[1] - 100.5).abs() < 1e-9, "{levels:?}"); - assert!((levels[2] - 100.25).abs() < 1e-9, "{levels:?}"); - assert!((levels[3] - 100.125).abs() < 1e-9, "{levels:?}"); - assert!((levels[4] - 100.1).abs() < 1e-9, "the floor: {levels:?}"); - assert_eq!(levels.len(), 5, "no step past the floor"); - // The print at 100.2 at t=5000 crosses the line at 100.1. - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 5_000)); - assert!((w.exit.price - 100.1).abs() < 1e-9); -} - -#[test] -fn the_placed_level_is_rounded_to_the_step() { - // ARX, 2026-09-21, step 0.0001: take 0.197802 goes to the book as 0.1978; the 20 % - // relative steps chain off the placed prices (0.1978 → 0.19714 → 0.1971 → 0.19658), and - // the floor at +1 % (0.196445) is placed at 0.1964 — which is why the print AT 0.1964 - // sells. - let p = ExitParams { - price_down_timer_s: 3.0, - price_down_pct: 20.0, - price_down_delay_s: 10.0, - price_down_relative: true, - price_down_allowed_drop_pct: 1.0, - ..params() - }; - let mut d = deal(false); - d.buy_price = 0.1945; - d.tick = Some(0.0001); - let fill = Fill { - t_ms: 0, - price: 0.1945, - }; - let ticks = tape(&[ - (1_000, 0.1950), - (4_000, 0.1950), - (14_000, 0.1950), - (24_000, 0.1950), - (34_000, 0.1950), - (40_000, 0.1964), - ]); - let w = walk(&d, &ticks, fill, 0.197802, &p); - let levels: Vec = w.points.iter().map(|pt| pt.price).collect(); - let on_grid = |v: f64| (v / 0.0001).round() * 0.0001; - assert_eq!(levels.len(), 4, "{levels:?}"); - for (level, want) in levels.iter().zip([0.1978, 0.1971, 0.1966, 0.1964]) { - assert!((level - want).abs() < 1e-9, "{levels:?}"); - assert!( - (level - on_grid(*level)).abs() < 1e-9, - "off the grid: {level}" - ); - } - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 40_000)); - assert!( - (w.exit.price - 0.1964).abs() < 1e-9, - "sold at the placed level" - ); -} - -#[test] -fn without_a_step_the_placed_level_is_the_exact_one() { - let p = ExitParams { - price_down_timer_s: 3.0, - price_down_pct: 20.0, - price_down_delay_s: 10.0, - price_down_relative: true, - price_down_allowed_drop_pct: 1.0, - ..params() - }; - let mut d = deal(false); - d.buy_price = 0.1945; - let fill = Fill { - t_ms: 0, - price: 0.1945, - }; - let ticks = tape(&[(1_000, 0.1950), (40_000, 0.1964)]); - let w = walk(&d, &ticks, fill, 0.197802, &p); - assert_eq!( - w.exit.kind, - ExitKind::OpenAtWindowEnd, - "0.196445 is above the tape's high" - ); -} - -#[test] -fn price_down_absolute_takes_a_share_of_the_price() { - // SellPrice 1 %, pct 0.2 absolute: 101 -> 100.8 (of the buy price). - let p = ExitParams { - price_down_timer_s: 1.0, - price_down_pct: 0.2, - price_down_relative: false, - price_down_allowed_drop_pct: 0.0, - ..params() - }; - let ticks = tape(&[(1_500, 100.0), (2_000, 100.9)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - assert!((w.points[1].price - 100.8).abs() < 1e-9, "{:?}", w.points); - assert_eq!(w.exit.kind, ExitKind::Line); -} - -#[test] -fn a_zero_timer_never_starts_the_steps() { - let p = ExitParams { - price_down_timer_s: 0.0, - price_down_pct: 50.0, - ..params() - }; - let ticks = tape(&[(5_000, 100.0), (60_000, 100.5)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - assert_eq!(w.points.len(), 1); - assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd, "nothing closed it"); -} - -#[test] -fn a_zero_delay_steps_at_the_terminal_floor() { - let p = ExitParams { - price_down_timer_s: 1.0, - price_down_pct: 50.0, - price_down_delay_s: 0.0, - price_down_allowed_drop_pct: -1.0, - ..params() - }; - let ticks = tape(&[(1_000, 100.0), (1_660, 100.0)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - // t=1000, 1330, 1660: three steps by the second print. - assert_eq!(w.points.len(), 4, "{:?}", w.points); - assert_eq!(w.points[2].t_ms, 1_330); -} - -// ---- SellLevel ------------------------------------------------------------------------------- - -#[test] -fn sell_level_moves_to_the_look_back_high_adjusted() { - // Delay 2 s, look back 10 s, adjust -1 %: the high of the last 10 s at t=2000 is 103 -> - // 101.97; once (count 1). - let p = ExitParams { - sell_level_delay_s: 2.0, - sell_level_time_s: 10.0, - sell_level_count: 1, - sell_level_adjust_pct: -1.0, - sell_level_allowed_drop_pct: 0.0, - ..params() - }; - let ticks = tape(&[(500, 103.0), (1_000, 100.5), (2_000, 100.5), (3_000, 102.0)]); - let w = walk(&deal(false), &ticks, fill(), 105.0, &p); - assert!((w.points[1].price - 101.97).abs() < 1e-9, "{:?}", w.points); - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 3_000)); -} - -#[test] -fn sell_level_relative_takes_a_share_of_the_distance_to_the_buy() { - let p = ExitParams { - sell_level_delay_s: 1.0, - sell_level_time_s: 10.0, - sell_level_count: 1, - sell_level_adjust_pct: 50.0, - sell_level_relative: true, - ..params() - }; - let ticks = tape(&[(500, 110.0), (1_000, 100.0)]); - let w = walk(&deal(false), &ticks, fill(), 120.0, &p); - assert!((w.points[1].price - 105.0).abs() < 1e-9, "{:?}", w.points); -} - -// ---- SellShot --------------------------------------------------------------------------------- - -#[test] -fn sell_shot_follows_the_market_inside_its_corridor() { - // Distance 1 %, corridor 50 %: the line stays while 0.5-1.5 % off the reference. The - // take at 101 is 1 % off 100; a rise to 100.8 leaves it 0.2 % off -> too close -> after - // the raise wait (0) it moves away to 101.808. - let p = ExitParams { - ignore_sell_shot: false, - sell_shot_distance_pct: 1.0, - sell_shot_corridor_pct: 50.0, - sell_shot_calc_interval_s: 0.1, - sell_shot_allowed_up_pct: 10.0, - sell_shot_allowed_down_pct: -1.0, - ..params() - }; - let ticks = tape(&[(500, 100.0), (1_000, 100.8), (1_500, 101.5)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - assert!((w.points[1].price - 101.808).abs() < 1e-4, "{:?}", w.points); - // 101.5 stays under the moved line, and the tape ends before the report's close. - assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd); -} - -#[test] -fn sell_shot_is_capped_by_allowed_up() { - let p = ExitParams { - ignore_sell_shot: false, - sell_shot_distance_pct: 1.0, - sell_shot_corridor_pct: 50.0, - sell_shot_calc_interval_s: 0.1, - sell_shot_allowed_up_pct: 0.5, - sell_shot_allowed_down_pct: -1.0, - ..params() - }; - let ticks = tape(&[(500, 100.0), (1_000, 100.8)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - assert!((w.points[1].price - 100.5).abs() < 1e-4, "{:?}", w.points); -} - -// ---- StopLoss --------------------------------------------------------------------------------- - -#[test] -fn the_stop_fires_on_the_print_after_its_delay() { - let p = ExitParams { - stop_loss_pct: -1.0, - stop_loss_delay_s: 2.0, - ..params() - }; - // A print through the stop inside the delay does not fire; one after it does, at the - // print's price (a market exit). - let ticks = tape(&[(1_000, 98.5), (3_000, 98.7)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 3_000)); - assert!((w.exit.price - 98.7).abs() < 1e-4); -} - -fn sold(t_ms: i64, price: f64) -> Tick { - Tick { - side: TickSide::Sell, - ..tick(t_ms, price) - } -} - -/// A book-watching stop (`FastStopLoss` off) that reads the bare ticker price: `StopLossEMA` -/// neither 0 (which adds the series) nor 3, 5, 10 (which average). -fn bare_book_stop() -> ExitParams { - ExitParams { - stop_loss_pct: -1.0, - fast_stop_loss: false, - stop_loss_ema: 7.0, - ..params() - } -} - -/// The book-watching stop (`FastStopLoss` off) reads the ticker's BID through the prints that -/// hit it — taker sells — on the ticker's clock, not every print through the level. -#[test] -fn the_book_stop_fires_on_a_sample_of_the_bid_not_on_a_print() { - let book = bare_book_stop(); - // A taker BUY through the level says nothing about the BID; the taker sell at 98.8 does, - // and the next arrival after it — 4.3 s — fires, at the proxy's price. - let ticks = vec![ - tick(1_000, 98.5), - sold(1_500, 99.5), - sold(2_500, 98.8), - tick(5_000, 100.0), - ]; - let w = walk(&deal(false), &ticks, fill(), 101.0, &book); - assert_eq!( - (w.exit.kind, w.exit.t_ms), - (ExitKind::Stop, 2 * TICKER_PERIOD_MS) - ); - assert!((w.exit.price - 98.8).abs() < 1e-4); - // The fast stop takes the first print through the level, whichever side it hit. - let fast = ExitParams { - fast_stop_loss: true, - ..book.clone() - }; - let w = walk(&deal(false), &ticks, fill(), 101.0, &fast); - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 1_000)); -} - -/// A sample due exactly at the tape's last print reads that print — the loop only reaches the -/// samples before a print, so the tape's end is where it must not be forgotten. -#[test] -fn the_book_stop_takes_the_sample_at_the_last_print() { - let book = bare_book_stop(); - let w = walk( - &deal(false), - &[sold(TICKER_PERIOD_MS, 98.8)], - fill(), - 101.0, - &book, - ); - assert_eq!( - (w.exit.kind, w.exit.t_ms), - (ExitKind::Stop, TICKER_PERIOD_MS) - ); - // A sample the tape ends before is not taken: nothing is known past the last print. - let w = walk(&deal(false), &[sold(1_500, 98.8)], fill(), 101.0, &book); - assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd); -} - -/// `StopLossEMA` 3 averages the ticker's arrivals as the core does, `(avg·2 + bid)/3`, so a BID -/// just past the level fires only once the average is past it too. -#[test] -fn the_stop_ema_waits_for_the_average() { - let ticks = vec![ - sold(1_500, 99.5), - sold(2_500, 98.9), - sold(9_000, 98.9), - sold(13_000, 98.9), - ]; - let bare = bare_book_stop(); - let w = walk(&deal(false), &ticks, fill(), 101.0, &bare); - assert_eq!( - (w.exit.kind, w.exit.t_ms), - (ExitKind::Stop, 2 * TICKER_PERIOD_MS) - ); - // Arrivals 99.5, then 98.9 from the second on: the average reads 99.5, 99.3, 99.167, - // 99.078, 99.019, 98.979 — past 99 at the sixth. An EMA of 2/(N + 1) = 0.5 would have - // crossed at the fourth. - let smoothed = ExitParams { - stop_loss_ema: 3.0, - ..bare - }; - let w = walk(&deal(false), &ticks, fill(), 101.0, &smoothed); - assert_eq!( - (w.exit.kind, w.exit.t_ms), - (ExitKind::Stop, 6 * TICKER_PERIOD_MS) - ); -} - -/// The core keeps the average from its start, so at the fill it remembers the prices before it: -/// the prints before the fill warm it, and can never fire it. -#[test] -fn the_stop_average_is_warm_at_the_fill() { - let smoothed = ExitParams { - stop_loss_ema: 3.0, - ..bare_book_stop() - }; - let after = [sold(1_500, 98.9), sold(30_000, 98.9)]; - let cold = walk(&deal(false), &after, fill(), 101.0, &smoothed); - assert_eq!( - (cold.exit.kind, cold.exit.t_ms), - (ExitKind::Stop, TICKER_PERIOD_MS) - ); - // A minute at 101 before the fill: the average starts there, seven arrivals of 98.9 leave - // it at 101 · (2/3)^7 + 98.9 · (1 − (2/3)^7) = 99.02, and the eighth brings it to 98.98. - let mut warm: Vec = (0..30).map(|i| sold(-60_000 + i * 2_000, 101.0)).collect(); - warm.extend(after); - let w = walk(&deal(false), &warm, fill(), 101.0, &smoothed); - assert_eq!(w.exit.kind, ExitKind::Stop); - assert_eq!(w.exit.t_ms, 8 * TICKER_PERIOD_MS, "{w:?}"); - // The prints before the fill, past the level, fire nothing before it. - let early = vec![sold(-3_000, 98.0), tick(500, 100.0)]; - let w = walk(&deal(false), &early, fill(), 101.0, &bare_book_stop()); - assert!(w.exit.t_ms > 0, "{w:?}"); -} - -/// A short's book stop watches the bare ASK: `StopLossEMA` averages a long's BID only. -#[test] -fn a_short_book_stop_does_not_average() { - let smoothed = ExitParams { - stop_loss_ema: 3.0, - ..bare_book_stop() - }; - // A taker buy prints at the ASK: 100 before the fill, then 101.1 past a short's stop at 101 - // — which an average of the two, 100.37, would not be. - let ticks = vec![tick(-1_000, 100.0), tick(1_500, 101.1), tick(5_000, 100.0)]; - let w = walk(&deal(true), &ticks, fill(), 99.0, &smoothed); - assert_eq!( - (w.exit.kind, w.exit.t_ms), - (ExitKind::Stop, TICKER_PERIOD_MS) - ); -} - -/// At `StopLossEMA` 0 the core's price series fires the stop too: a lone print past the level -/// is its tick's point, and fires when the tick closes; a spike among prints near the last -/// point is not the point, and waits for the ticker. -#[test] -fn a_stop_without_averaging_fires_on_the_series_point() { - let series = ExitParams { - stop_loss_ema: 0.0, - ..bare_book_stop() - }; - let lone = vec![tick(1_000, 98.5), tick(5_000, 100.0)]; - let w = walk(&deal(false), &lone, fill(), 101.0, &series); - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 1_250)); - assert!((w.exit.price - 98.5).abs() < 1e-4); - // The same print after a point at 100 and beside 99.9 in its tick: the point is 99.9. - let spike = vec![ - tick(900, 100.0), - tick(1_000, 98.5), - tick(1_100, 99.9), - tick(5_000, 100.0), - ]; - let w = walk(&deal(false), &spike, fill(), 101.0, &series); - assert_ne!(w.exit.kind, ExitKind::Stop, "{w:?}"); - // At 7 the core reads the bare ticker price and no series. - let w = walk(&deal(false), &lone, fill(), 101.0, &bare_book_stop()); - assert_ne!(w.exit.kind, ExitKind::Stop, "{w:?}"); -} - -/// A book stop's sale is a panic sell walked through the book: the verdict holds the model's -/// stop level against the one the core printed, and the moment against the close — never the -/// sale price. A stop the core fired and the model never did is a miss. -#[test] -fn verify_judges_a_book_stop_by_its_level_and_moment() { - let book = ExitParams { - stop_loss_pct: -1.0, - fast_stop_loss: false, - ..params() - }; - let mut d = deal(false); - d.sell_reason = "StopLoss AutoActivated on price drop: BID = 98.800 ASK: 98.900 \ - (strategy ); StopLoss fixed: 99.000 AllowedDrop: BUY -15.0%" - .into(); - d.sell_price = 97.0; - d.close_ms = 4_050; - let ticks = vec![sold(1_500, 99.5), sold(2_500, 98.8), tick(5_000, 100.0)]; - let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); - assert_eq!(v.exit_kind, Some(ExitKind::Stop)); - assert_eq!(v.exit, Some(true), "{v:?}"); - assert!(v.exit_dev_pct.is_some_and(|dev| dev.abs() < 1e-9)); - // The stored reason cut inside the level: still the book stop, judged by its moment. - let full = d.sell_reason.clone(); - d.sell_reason = full[..full.find("99.000").expect("level") + 3].to_string(); - let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); - assert_eq!(v.exit, Some(true), "{v:?}"); - d.close_ms = 9_000; - let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); - assert_eq!(v.exit, Some(false), "five seconds off the moment, {v:?}"); - d.close_ms = 4_050; - d.sell_reason = full; - // The core's level elsewhere: not this stop. - d.sell_reason = d.sell_reason.replace("99.000", "98.000"); - let v = verify(&d, &ticks, &EntryParams::Fact, &book, None, None); - assert_eq!(v.exit, Some(false), "{v:?}"); - // A tape whose BID never reaches the level: the core stopped, the model did not. - let calm = vec![sold(1_500, 99.5), tick(5_000, 100.0)]; - let v = verify(&d, &calm, &EntryParams::Fact, &book, None, None); - assert_eq!(v.exit, Some(false), "{v:?}"); -} - -/// The take is an order like every move: on the book `latency_ms` after the core placed it. The -/// spike's own tail, printed in the milliseconds after the fill, cannot fill it. -#[test] -fn the_take_is_on_the_book_only_after_the_latency() { - let p = ExitParams { - model: ModelSettings { - latency_ms: 100.0, - ..ModelSettings::default() - }, - ..ExitParams::default() - }; - let ticks = tape(&[(9, 101.5), (150, 101.2)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Take, 150)); -} - -/// A market stop's sale sweeps our size through the book: with no level on record — its reason -/// never carries one — the verdict holds the moment it fired, never the sweep's price. -#[test] -fn verify_judges_a_market_stop_without_a_level_by_its_moment() { - let fast = ExitParams { - stop_loss_pct: -1.0, - fast_stop_loss: true, - ..params() - }; - let mut d = deal(false); - d.sell_reason = "StopLoss Market Sell".into(); - // 1.4 % past the print that fired it: the sweep, which no print on the tape shows. - d.sell_price = 97.5; - d.close_ms = 3_100; - let ticks = tape(&[(1_000, 99.5), (3_000, 98.9), (5_000, 99.0)]); - let v = verify(&d, &ticks, &EntryParams::Fact, &fast, None, None); - assert_eq!(v.exit_kind, Some(ExitKind::Stop)); - assert_eq!(v.exit, Some(true), "{v:?}"); - assert_eq!(v.exit_dev_pct, None); - d.close_ms = 9_000; - let v = verify(&d, &ticks, &EntryParams::Fact, &fast, None, None); - assert_eq!(v.exit, Some(false), "six seconds off the moment, {v:?}"); -} - -// ---- the mirror and the archive ------------------------------------------------------------- - -#[test] -fn a_short_mirrors_every_rule() { - let p = ExitParams { - price_down_timer_s: 1.0, - price_down_pct: 50.0, - price_down_delay_s: 1.0, - price_down_allowed_drop_pct: 0.1, - stop_loss_pct: -1.0, - ..params() - }; - // Take 99 (1 % below the buy at 100); one step -> 99.5 at t=1000; a print down to 99.4 - // before the next step crosses it. - let ticks = tape(&[(1_500, 100.0), (1_900, 99.4)]); - let w = walk(&deal(true), &ticks, fill(), 99.0, &p); - assert!((w.points[1].price - 99.5).abs() < 1e-9, "{:?}", w.points); - assert_eq!(w.exit.kind, ExitKind::Line); - assert!((w.exit.price - 99.5).abs() < 1e-9); - // The stop is ABOVE a short's buy. - let ticks = tape(&[(500, 101.2)]); - let w = walk(&deal(true), &ticks, fill(), 99.0, &p); - assert_eq!(w.exit.kind, ExitKind::Stop); -} - -#[test] -fn a_spike_through_the_old_level_fills_before_a_step_lands() { - let p = ExitParams { - price_down_timer_s: 1.0, - price_down_pct: 50.0, - model: ModelSettings { - latency_ms: 100.0, - ..ModelSettings::default() - }, - ..params() - }; - // The step is decided at t=1000 and reaches the book at t=1100; a print at 101 at t=1050 - // fills the old take at 101, not the new line at 100.5. - let ticks = tape(&[(1_000, 100.0), (1_050, 101.0)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - assert_eq!(w.exit.kind, ExitKind::Take); - assert!((w.exit.price - 101.0).abs() < 1e-9); -} - -#[test] -fn verify_holds_the_line_against_the_archived_points() { - let p = ExitParams { - price_down_timer_s: 1.0, - price_down_pct: 50.0, - price_down_delay_s: 1.0, - price_down_allowed_drop_pct: 0.1, - ..params() - }; - let mut d = deal(false); - d.sell_price = 100.25; - let ticks = tape(&[(0, 100.0), (1_500, 100.0), (2_500, 100.3)]); - // The archive's points are what the core did: placed at 101, 100.5 at 1 s, 100.25 at 2 s. - let archived = [(0, 101.0), (1_000, 100.5), (2_000, 100.25)]; - let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&archived)); - assert_eq!(v.exit_kind, Some(ExitKind::Line)); - assert_eq!(v.exit, Some(true), "{v:?}"); - assert_eq!(v.line_points, Some((3, 3))); - // A line the core moved differently - a point the model never re-placed at - is a miss - // even when the exit price agrees. - let other = [(0, 101.0), (1_000, 100.7), (2_000, 100.25)]; - let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&other)); - assert_eq!(v.exit, Some(false)); - assert_eq!(v.line_points, Some((2, 3))); - // The same walk through `ExitModel`. - let w = ExitModel::new(&p).walk(&d, &ticks, fill()); - assert_eq!(w.points.len(), 3); -} - -// ---- the core's sell price across a step -------------------------------------------------------- - -/// Relative PriceDown on the grid, flat tape under the line: `(t, level)` of every point. -fn grid_walk(take: f64, step_lag_ms: f64, ticks: &[Tick]) -> Vec<(i64, f64)> { - let p = ExitParams { - price_down_timer_s: 1.0, - price_down_pct: 10.0, - price_down_delay_s: 1.0, - price_down_relative: true, - price_down_allowed_drop_pct: 0.1, - ..params() - }; - let mut d = deal(false); - d.buy_price = 1_000.0; - d.tick = Some(1.0); - d.step_lag_ms = step_lag_ms; - let fill = Fill { - t_ms: 0, - price: 1_000.0, - }; - walk(&d, ticks, fill, take, &p) - .points - .iter() - .map(|pt| (pt.t_ms, pt.price)) - .collect() -} - -/// A step that moved the order chains the next one off the ORDER's price: 1096 → 1086.4, placed -/// 1086; then 1086 → 1077.4, placed 1077 — off the exact 1086.4 it would round to 1078. INDEX -/// 2026-09-22 (Gate, 10 % relative): all twelve archived levels land only this way. -#[test] -fn a_moved_order_chains_off_its_placed_price() { - let ticks = tape(&[(500, 1_000.0), (1_500, 1_000.0), (2_500, 1_000.0)]); - assert_eq!( - grid_walk(1_096.0, 0.0, &ticks), - vec![(0, 1_096.0), (1_000, 1_086.0), (2_000, 1_077.0)] - ); -} - -/// A step that rounds back onto the order sends nothing and carries its exact value into the -/// next: 1004 → 1003.6 (still 1004), → 1003.24 (1003), → 1002.7 (still 1003), → 1002.43 (1002). -/// Off the placed price alone the line would never leave 1004; off the exact chain the second -/// move would come a step late. FATCOIN 2026-09-22 climbs one tick every other step this way. -#[test] -fn a_step_rounding_back_onto_the_order_carries_its_exact_value() { - let ticks = tape(&[ - (500, 1_000.0), - (1_500, 1_000.0), - (2_500, 1_000.0), - (3_500, 1_000.0), - (4_500, 1_000.0), - ]); - assert_eq!( - grid_walk(1_004.0, 0.0, &ticks), - vec![(0, 1_004.0), (2_000, 1_003.0), (4_000, 1_002.0)] - ); -} - -/// The core's own replace lag spaces the steps: the first is timed off the take by -/// `PriceDownTimer`, the next one after a step that moved the order off that step plus the delay -/// plus the lag — here every step moves the order. -#[test] -fn the_core_step_lag_spaces_the_price_down_steps() { - let ticks = tape(&[ - (500, 1_000.0), - (1_500, 1_000.0), - (2_500, 1_000.0), - (3_500, 1_000.0), - ]); - let times: Vec = grid_walk(1_096.0, 50.0, &ticks) - .iter() - .map(|&(t, _)| t) - .collect(); - assert_eq!(times, vec![0, 1_000, 2_050, 3_100]); -} - -/// A step rounding kept in place sent nothing, so the next one waits for no round trip: the -/// lag follows only the steps that moved the order (1004 stays at 1 s, 1003 at 2 s, 1003 stays -/// at 3.05 s, 1002 at 4.05 s). -#[test] -fn a_step_kept_in_place_adds_no_lag() { - let ticks = tape(&[ - (500, 1_000.0), - (1_500, 1_000.0), - (2_500, 1_000.0), - (3_500, 1_000.0), - (4_500, 1_000.0), - ]); - assert_eq!( - grid_walk(1_004.0, 50.0, &ticks), - vec![(0, 1_004.0), (2_000, 1_003.0), (4_050, 1_002.0)] - ); -} - -// ---- PumpMove ------------------------------------------------------------------------------ - -/// `PumpMoveTimer` after the take, once: to `PumpMovePersent` of the peak-to-buy distance short -/// of the pump's peak — which printed before the buy. Peak 110, buy 100, 1 %: 109.9. -#[test] -fn the_pump_move_goes_once_to_the_peak_less_its_share() { - let p = ExitParams { - pump_move_timer_s: 2.0, - pump_move_pct: 1.0, - ..params() - }; - let mut d = deal(false); - d.kind = "PumpsDetection".into(); - d.tick = Some(0.01); - let ticks = tape(&[ - (-3_000, 104.0), - (-50, 110.0), - (500, 101.0), - (1_500, 102.0), - (2_600, 101.5), - (6_000, 101.0), - ]); - let w = walk(&d, &ticks, fill(), 104.0, &p); - let points: Vec<(i64, f64)> = w.points.iter().map(|pt| (pt.t_ms, pt.price)).collect(); - assert_eq!(points.len(), 2, "{points:?}"); - assert_eq!(points[1].0, 2_000 + PUMP_MOVE_LAG_MS); - assert!((points[1].1 - 109.9).abs() < 1e-9, "{points:?}"); -} - -// ---- the verdict's clock --------------------------------------------------------------------- - -/// PriceDown 50 % relative every second, 100 ms to the book: the take and its steps. -fn clock_params(take_pct: f64) -> ExitParams { - ExitParams { - sell_price_pct: take_pct, - price_down_timer_s: 1.0, - price_down_pct: 50.0, - price_down_delay_s: 1.0, - price_down_allowed_drop_pct: 0.1, - model: ModelSettings { - latency_ms: 100.0, - ..ModelSettings::default() - }, - ..params() - } -} - -/// The core stepped onto 100.5 at 900 ms and sold there 100 ms before the close — the archive -/// files the step and the fill as one point. The model's step to 100.5 is stamped 1 100 ms: late -/// by 200 ms, inside the point tolerance. On the model's own clock the fill found the take still -/// standing and the verdict had nothing to say; on the archive's the line stood at 100.5. -#[test] -fn the_level_at_the_fill_is_read_on_the_archive_clock() { - let mut d = deal(false); - d.sell_price = 100.5; - d.close_ms = 1_000; - let ticks = tape(&[(0, 100.0), (500, 100.0), (1_500, 100.0)]); - let archived = [(0, 101.0), (900, 100.5)]; - let v = verify( - &d, - &ticks, - &EntryParams::Fact, - &clock_params(1.0), - None, - Some(&archived), - ); - assert_eq!(v.exit_kind, Some(ExitKind::Line), "{v:?}"); - assert_eq!(v.exit, Some(true), "{v:?}"); -} - -/// A step the model took with no archived move to match: well before the fill it is a step the -/// core never took, and its level is the answer; inside the point tolerance of the fill it is -/// the model's timing, and the core's last level is. -#[test] -fn a_stray_step_counts_only_outside_the_point_tolerance_of_the_fill() { - // Take 102; the model steps to 101 (1 100 ms), 100.5 (2 100 ms), 100.25 (3 100 ms). The - // core stepped once, to 101, and filled a touch above it. - let mut d = deal(false); - d.sell_price = 101.02; - let ticks = tape(&[ - (0, 100.0), - (500, 100.0), - (1_500, 100.0), - (2_500, 100.0), - (3_500, 100.0), - ]); - let p = clock_params(2.0); - // Filled at 3 450 ms: the model's 100.5 at 2 100 ms is more than a second before it. - d.close_ms = 3_600; - let late = [(0, 102.0), (1_000, 101.0), (3_450, 101.02)]; - let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&late)); - assert_eq!(v.exit, Some(false), "{v:?}"); - // Filled at 2 500 ms: the model's 100.5 came 400 ms before it. - d.close_ms = 2_600; - let soon = [(0, 102.0), (1_000, 101.0), (2_500, 101.02)]; - let v = verify(&d, &ticks, &EntryParams::Fact, &p, None, Some(&soon)); - assert_eq!(v.exit, Some(true), "{v:?}"); -} - -/// The fact's sell timers run from the take the core placed — the archive's first point, less -/// `SellDelay` — when that is later than the report's buy stamp. -#[test] -fn the_fact_sell_starts_at_the_archived_take() { - let d = deal(false); - let p = params(); - let start = - |points: Option<&[(i64, f64)]>, p: &ExitParams| verify::fact_sell_start(&d, p, points).t_ms; - assert_eq!(start(Some(&[(2_000, 101.0), (3_000, 100.5)]), &p), 2_000); - let delayed = ExitParams { - sell_delay_ms: 500.0, - ..params() - }; - assert_eq!(start(Some(&[(2_000, 101.0)]), &delayed), 1_500); - assert_eq!(start(Some(&[(-7, 101.0)]), &p), 0, "never before the buy"); - assert_eq!(start(None, &p), 0); -} diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 7cf959e9..3b06c0fe 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -31,7 +31,6 @@ pub mod deltas; pub mod entry; pub mod exit; pub mod hook; -pub mod line; pub mod mshot; pub mod params; pub mod record; @@ -43,7 +42,8 @@ pub mod verify; pub use deals::{DealsRead, read_deals}; pub use entry::{EntryModel, entry_model_for}; -pub use exit::{ExitModel, ExitParams, archived_pre_spike_ask, archived_take, take_model_for}; +pub use exit::sell_order::{archived_pre_spike_ask, archived_take, take_model_for}; +pub use exit::{ExitModel, ExitParams}; pub use hook::{HookDetect, KIND_MOONHOOK, hook_take_pct, parse_hook_detect}; pub use mshot::{CorridorStep, EntryMethod, MshotEntry, MshotParams, UsePrice}; pub use params::{ParamGroup, ParamKind, TICK_PARAMS, TickParam}; diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index f7c31afc..e7dd6af1 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -97,8 +97,11 @@ pub struct TickParam { const MSHOT: &[&str] = &["MoonShot"]; const HOOK: &[&str] = &[super::hook::KIND_MOONHOOK]; /// The kinds whose take `SellPrice` does not place: MoonHook's is `HookSellLevel`, Spread's the -/// edge of the spread it detected (`exit::take_is_recorded`). -const NOT_SELL_PRICE: &[&str] = &[super::hook::KIND_MOONHOOK, super::exit::KIND_SPREAD]; +/// edge of the spread it detected (`exit::sell_order::take_is_recorded`). +const NOT_SELL_PRICE: &[&str] = &[ + super::hook::KIND_MOONHOOK, + super::exit::sell_order::KIND_SPREAD, +]; const ANY: &[&str] = &[]; const GRID_PRICE: &[f64] = &[ @@ -475,12 +478,17 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "UseStopLoss", "FastStopLoss", "StopLossEMA", - // The switches of the sell rules the model does not have: a trade under one is not - // modelled (`exit::UnmodelledRule`). + // The trailing stop (`exit::stops::trailing`): read as the strategy sets it, not a knob yet. "UseTrailing", + "TrailingPercent", + "TrailingEMA", + "UseTakeProfit", + "TakeProfit", + // The switches of the stop ladder, which the model does not have: a trade under one is not + // modelled (`exit::UnmodelledRule`). "UseSecondStop", "UseStopLoss3", - // PumpsDetection's one sell move (see `line::PUMP_MOVE_LAG_MS`); `PumpMovePersent` is the + // PumpsDetection's one sell move (see `exit::pump_move::PUMP_MOVE_LAG_MS`); `PumpMovePersent` is the // core's own spelling of the field. "PumpMoveTimer", "PumpMovePersent", @@ -686,6 +694,16 @@ pub fn exit_params(v: &StrategyValues<'_>, model: ModelSettings) -> ExitParams { // the book-watching stop, never as the fast stop's "StopLoss Market Sell". fast_stop_loss: v.bool("FastStopLoss", false), stop_loss_ema: v.num("StopLossEMA", base.stop_loss_ema), + // Every trailing field hangs on `UseTrailing` (param_deps.toml), and `TakeProfit` on + // `UseTakeProfit` too; the values stay in the dump with the switches off. + trailing_pct: if v.bool("UseTrailing", false) { + v.num("TrailingPercent", base.trailing_pct) + } else { + 0.0 + }, + trailing_ema: v.num("TrailingEMA", base.trailing_ema), + trailing_take_profit_pct: (v.bool("UseTrailing", false) && v.bool("UseTakeProfit", false)) + .then(|| v.num("TakeProfit", 0.0)), unmodelled: unmodelled_rule(v), model, take_from_archive: base.take_from_archive, @@ -697,11 +715,10 @@ pub fn exit_params(v: &StrategyValues<'_>, model: ModelSettings) -> ExitParams { /// Read by the switch, never by its fields: the fields stay in a strategy's dump with the switch /// off (`assets/param_deps.toml`). On this machine's reports (2026-09-23) the trailing stop was /// on for the strategies of 44 trades of 2 042 and the stop ladder for 2; `SellSpread` and the EMA exit -/// were on for none, and are left out until a strategy turns them on. +/// were on for none, and are left out until a strategy turns them on. The trailing stop is +/// modelled since 2026-09-24 (`exit::stops::trailing`). fn unmodelled_rule(v: &StrategyValues<'_>) -> Option { - if v.bool("UseTrailing", false) { - Some(UnmodelledRule::Trailing) - } else if v.bool("UseSecondStop", false) || v.bool("UseStopLoss3", false) { + if v.bool("UseSecondStop", false) || v.bool("UseStopLoss3", false) { Some(UnmodelledRule::StopLadder) } else { None diff --git a/crates/moon-core/src/db/tuner/ticks/record.rs b/crates/moon-core/src/db/tuner/ticks/record.rs index bb6ea5df..0a540e72 100644 --- a/crates/moon-core/src/db/tuner/ticks/record.rs +++ b/crates/moon-core/src/db/tuner/ticks/record.rs @@ -17,10 +17,11 @@ //! proxy, and the verdict (which replays the proxy, never the anchor) is what says how far the //! proxy may be trusted. -use super::exit::{ExitParams, archived_pre_spike_ask, archived_take, stop_pct}; +use super::exit::ExitParams; +use super::exit::sell_order::{archived_pre_spike_ask, archived_take}; +use super::exit::stops::stop_pct; use super::verify::{ - POINT_TIME_TOLERANCE_MS, REASON_STOP, Verdict, archived_stop_jump, reason_starts_with, - stated_stop_level, + POINT_TIME_TOLERANCE_MS, Verdict, archived_stop_jump, is_stop_reason, stop_jump_level, }; use super::{Deal, EntryParams, Fill}; @@ -45,9 +46,11 @@ impl StopAnchor { /// The anchor of a trade whose fact ran `exit`. /// /// The moment a stopped trade's stop fired: the order archive's first move at or past the - /// stop's level (the panic sell's jump), else the close — the sale completes within a second - /// or two of the activation, and on a trade with no record of the moment that is the nearest - /// the fact gets. + /// level `verify::stop_jump_level` gives (the panic sell's jump) — the stop's, or a trailing + /// stop's own line under its printed peak — else the close: the sale completes within a + /// second or two of the activation, and on a trade with no record of the moment (no archive, + /// no such level, a trailing stop rewritten to a market sale) that is the nearest the fact + /// gets. /// /// Args: /// deal: The trade. @@ -55,16 +58,13 @@ impl StopAnchor { /// exit_points: The archived Exit line, when the archive holds it. pub fn of(deal: &Deal, exit: &ExitParams, exit_points: Option<&[(i64, f64)]>) -> Self { let pct = stop_pct(exit, deal, deal.buy_ms); - // The verdict's own test of a stopped fact (`verify::reason_starts_with`), not a copy. - let stopped = reason_starts_with(deal.sell_reason.trim(), REASON_STOP); + // The verdict's own test of a stopped fact (`verify::is_stop_reason`), not a copy. + let stopped = is_stop_reason(&deal.sell_reason); let fired = stopped.then(|| { - // The level the jump is read against: the core's own when its reason kept it. - let level = stated_stop_level(&deal.sell_reason).unwrap_or(if deal.is_long() { - deal.buy_price * (1.0 + pct / 100.0) - } else { - deal.buy_price * (1.0 - pct / 100.0) - }); - let at = archived_stop_jump(deal, level, exit_points).unwrap_or(deal.close_ms); + // The level the jump is read against — the verdict's own (`verify::stop_jump_level`). + let at = stop_jump_level(deal, exit) + .and_then(|level| archived_stop_jump(deal, level, exit_points)) + .unwrap_or(deal.close_ms); (at, deal.sell_price) }); Self { diff --git a/crates/moon-core/src/db/tuner/ticks/record/tests.rs b/crates/moon-core/src/db/tuner/ticks/record/tests.rs index 577b42b7..05e08e3f 100644 --- a/crates/moon-core/src/db/tuner/ticks/record/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -2,7 +2,7 @@ //! the search's sample. use super::*; -use crate::db::tuner::ticks::line::walk; +use crate::db::tuner::ticks::exit::line::walk; use crate::db::tuner::ticks::mshot::MshotParams; use crate::db::tuner::ticks::{Deltas, ExitKind, ModelSettings, simulate, verify}; use crate::feed::types::{Side, Tick}; diff --git a/crates/moon-core/src/db/tuner/ticks/settings.rs b/crates/moon-core/src/db/tuner/ticks/settings.rs index c3629b98..0c431a2e 100644 --- a/crates/moon-core/src/db/tuner/ticks/settings.rs +++ b/crates/moon-core/src/db/tuner/ticks/settings.rs @@ -4,13 +4,13 @@ //! variant columns and the search alike, so the three can never judge a trade by different rules. //! //! Every default is the measured constant it replaces; the measurement stays on the constant -//! (`mshot::DEFAULT_LATENCY_MS`, `line::TICKER_PERIOD_MS`, `verify::POINT_TIME_TOLERANCE_MS`, …). +//! (`mshot::DEFAULT_LATENCY_MS`, `exit::stops::TICKER_PERIOD_MS`, `verify::POINT_TIME_TOLERANCE_MS`, …). use serde::{Deserialize, Serialize}; -use super::line::{ - PUMP_MOVE_LAG_MS, PUMP_PEAK_LOOKBACK_MS, SERIES_TICK_MS, STEP_FLOOR_MS, TICKER_PERIOD_MS, -}; +use super::exit::pump_move::{PUMP_MOVE_LAG_MS, PUMP_PEAK_LOOKBACK_MS}; +use super::exit::sell_order::STEP_FLOOR_MS; +use super::exit::stops::{SERIES_TICK_MS, TICKER_PERIOD_MS}; use super::mshot::{ DEFAULT_LATENCY_MS, EntryMethod, FAST_ALGO_WINDOW_MS, PRE_SPIKE_LOOKBACK_MS, SHIFT_WINDOW_MS, }; diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index b9764cc1..31158212 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; -use super::exit::pre_spike_price; -use super::exit::stop_pct as moon_core_stop_pct; +use super::exit::sell_order::pre_spike_price; +use super::exit::stops::stop_pct as moon_core_stop_pct; use super::mshot::{Modifiers, PRE_SPIKE_LOOKBACK_MS}; use super::params::{StrategyValues, exit_params, mshot_params, param_keys, params_for}; use super::verify::share; @@ -1424,7 +1424,7 @@ fn the_descriptor_keys_every_field_the_builders_read_and_splits_the_groups() { .map(|p| p.key) .collect(); assert!(exit_any.starts_with(&["SellPrice", "SellDelay", "PriceDownTimer"])); - // A Spread's take is the spread it detected, not `SellPrice` (`exit::take_is_recorded`). + // A Spread's take is the spread it detected, not `SellPrice` (`exit::sell_order::take_is_recorded`). let spread: Vec<_> = params_for(ParamGroup::Exit, "Spread") .map(|p| p.key) .collect(); @@ -1873,9 +1873,10 @@ fn a_cancelled_stop_does_not_fire_at_the_entry() { ); } -/// A short's stop sits ABOVE the entry, and the same distance mirrors there. +/// A short's stop sits ABOVE the entry, the adjusted distance included (`stops::stop_level` +/// divides the fill by it). #[test] -fn a_short_stop_mirrors_with_the_modifier() { +fn a_short_stop_sits_above_with_the_modifier() { let mut mods = Modifiers::default(); mods.add_1h = 1.0; let params = ExitParams { @@ -1891,7 +1892,7 @@ fn a_short_stop_mirrors_with_the_modifier() { }, ..short_deal() }; - // −2 − 0.2·5 = −3 per cent, and a short's stop is that far ABOVE the fill. + // −2 − 0.2·5 = −3 per cent, and a short's stop is the fill over 0.97: 103.09, ABOVE it. let walk = ExitModel::new(¶ms).walk( &d, &tape(&[(10_000, 100.0), (15_000, 103.5)]), @@ -1900,7 +1901,7 @@ fn a_short_stop_mirrors_with_the_modifier() { price: 100.0, }, ); - assert_eq!(walk.exit.kind, ExitKind::Stop, "the price crossed 103"); + assert_eq!(walk.exit.kind, ExitKind::Stop, "the price crossed 103.09"); assert!((walk.exit.price - 103.5).abs() < 1e-9, "{:?}", walk.exit); } diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 38074cdc..65d33123 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -89,7 +89,7 @@ fn dump_deal( deal: &Deal, values: &HashMap, ticks: &[Tick], - held: &super::super::line::LineWalk, + held: &super::super::exit::line::LineWalk, exit_points: Option<&[(i64, f64)]>, entry_points: Option<&[(i64, f64)]>, entry: &EntryParams, @@ -99,14 +99,11 @@ fn dump_deal( // The stop as the verdict reads it (`verify::verify_stop`): the model's level off the fact's // buy, the level the core printed into the reason, and the activation — the archive's jump // past that level. - let stop = super::super::exit::stop_pct(exit, deal, deal.buy_ms); - let level = if deal.is_long() { - deal.buy_price * (1.0 + stop / 100.0) - } else { - deal.buy_price * (1.0 - stop / 100.0) - }; + let stop = super::super::exit::stops::stop_pct(exit, deal, deal.buy_ms); + let level = super::super::exit::stops::stop_level(deal.buy_price, stop, deal.is_long()); let stated = verify::stated_stop_level(&deal.sell_reason); - let activation = verify::archived_stop_jump(deal, stated.unwrap_or(level), exit_points); + let activation = verify::stop_jump_level(deal, exit) + .and_then(|jump_at| verify::archived_stop_jump(deal, jump_at, exit_points)); let dir = PathBuf::from(dir); let _ = std::fs::create_dir_all(dir.join("ticks")); let row = serde_json::json!({ @@ -978,7 +975,7 @@ fn real_data_reproduction() { " held exit {:?} at {:+}ms of close · stop {:.3}% · model pts {}", held.exit.kind, held.exit.t_ms - deal.close_ms, - super::super::exit::stop_pct(&exit, &deal, deal.buy_ms), + super::super::exit::stops::stop_pct(&exit, &deal, deal.buy_ms), held.points.len() ); if let Some(points) = exit_points.as_deref() { diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index cb9d5700..48c1a0b2 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -43,8 +43,10 @@ //! sell or a market order walked through a book the tape does not carry: by the level the core //! fixed, when the stored reason keeps it, and the moment it activated ([`verify_stop`]). A stop the core fired and the model never did is a miss. -use super::exit::{ExitModel, stop_pct}; -use super::line::LinePoint; +use super::exit::ExitModel; +use super::exit::line::LinePoint; +use super::exit::stops::trailing::trailing_level; +use super::exit::stops::{stop_level, stop_pct}; use super::mshot::MshotParams; use super::settings::ModelSettings; use super::{ @@ -67,7 +69,7 @@ pub const BOOK_STOP_TIME_TOLERANCE_MS: i64 = 2_300; /// Tolerance on a STOP's level: the modelled level against the one the core fixed carries /// `StopLossModifier` over deltas the model only partly re-reads live — the coin's ranges where /// the deal has a track, the BTC, market, mark and price-bug terms as the report's one snapshot -/// (`exit::modifier_sum`) — and the residual sits right there. +/// (`exit::delta_mods::modifier_sum`) — and the residual sits right there. pub const STOP_PRICE_TOLERANCE: f64 = 0.003; /// How much BETTER than the modelled level the fact's fill may be and still be that level's @@ -171,7 +173,7 @@ pub fn verify( // the line as it stood when the core sold — the last level the exchange had been given // by then, the model's own latency allowed for — and the rule is the take when no move // had reached the exchange, the moving line otherwise. - let fact_stopped = reason_starts_with(deal.sell_reason.trim(), REASON_STOP); + let fact_stopped = is_stop_reason(&deal.sell_reason); // The archive's own record of the sell: its moves, and the fill it filed as a point. let archive = exit_points .filter(|p| !p.is_empty()) @@ -231,9 +233,15 @@ pub fn verify( // A stop the core fired and the model, holding a stop of its own, never did — the book // proxy of a non-fast stop can stay short of the level to the tape's end — is a miss of // the stop, whatever the line was doing: not a question about another rule. + // The trailing answers for it only where the fact's reason can be the trailing's: its own, or + // the market sale the core rewrites it to — never a stop that printed its fixed level. + let reason = deal.sell_reason.trim(); + let trailing_can_be_it = reason_starts_with(reason, REASON_TRAILING) + || reason.eq_ignore_ascii_case(REASON_MARKET_STOP); let missed_stop = fact_stopped && closed.kind != ExitKind::Stop - && stop_pct(&fact_exit, deal, deal.buy_ms) != 0.0; + && (stop_pct(&fact_exit, deal, deal.buy_ms) != 0.0 + || (fact_exit.trailing_pct != 0.0 && trailing_can_be_it)); let (exit_ok, exit_dev, line_points) = if exit.unmodelled.is_some() { // A rule the model does not have was on: whatever the walk made of the trade is not // an answer about it (see `ExitParams::unmodelled`). @@ -470,10 +478,15 @@ fn is_fill_point(deal: &Deal, exit: &ExitParams, last: (i64, f64), prev: (i64, f /// /// The level tolerance is [`STOP_PRICE_TOLERANCE`] rather than the line's: the modelled level /// carries `StopLossModifier` over deltas the model only partly re-reads live (see -/// `exit::modifier_sum`), and the residual sits right there. +/// `exit::delta_mods::modifier_sum`), and the residual sits right there. /// -/// Archived moves from the activation on — the first move past the stop level — are the panic -/// sell, not the line the rules moved, and are not held against the model. +/// Archived moves from the activation on — the first move past the level [`stop_jump_level`] +/// gives: the stop's, or for a trailing stop's reason its own line under the printed peak — are +/// the panic sell, not the line the rules moved, and are not held against the model. A fact whose +/// level is on no record (a trailing stop rewritten to `StopLoss Market Sell`) keeps them. +/// +/// A trailing stop's reason is timed with the book stop's tolerance whatever `FastStopLoss` says: +/// the trailing fires on the ticker's arrivals, like the book stop. /// /// Args: /// deal: The report row. @@ -488,25 +501,25 @@ fn verify_stop( closed: Exit, exit_points: Option<&[(i64, f64)]>, ) -> (Option, Option, Option<(usize, usize)>) { - let stop = stop_pct(exit, deal, deal.buy_ms); - let level = if deal.is_long() { - deal.buy_price * (1.0 + stop / 100.0) - } else { - deal.buy_price * (1.0 - stop / 100.0) - }; + let level = stop_level( + deal.buy_price, + stop_pct(exit, deal, deal.buy_ms), + deal.is_long(), + ); let stated = stated_stop_level(&deal.sell_reason); - let panic_at = stated.unwrap_or(level); + let panic_at = stop_jump_level(deal, exit); let mut activation: Option = None; let points = exit_points.filter(|p| !p.is_empty()).map(|archived| { let mut moves = archived_replacements(archived); - if let Some(i) = stop_jump(deal, &moves, panic_at) { + if let Some(i) = panic_at.and_then(|level| stop_jump(deal, &moves, level)) { activation = Some(moves[i].0); moves.truncate(i); } (matched_points(modelled, &moves, &exit.model), moves.len()) }); let line_ok = points.is_none_or(|(matched, total)| matched == total); - let tolerance_ms = if exit.fast_stop_loss { + let trailing_fact = reason_starts_with(deal.sell_reason.trim(), REASON_TRAILING); + let tolerance_ms = if exit.fast_stop_loss && !trailing_fact { exit.model.point_time_ms } else { exit.model.book_stop_time_ms @@ -528,6 +541,43 @@ fn verify_stop( } } +/// The level an archived line's jump into the panic sell is read against: +/// +/// - a stop's reason (`REASON_STOP`): the level the core printed into it when it kept one, else +/// the stop's own — `None` for a strategy without a stop; +/// - a trailing stop's reason (`REASON_TRAILING`): the fact's own line at the activation, under +/// the `PeakPrice` the core printed ([`trailing_level`]) — `None` without a trailing setting or +/// a readable peak. +/// +/// `None` leaves the moment to the close. A `StopLoss Market Sell` can still be a trailing stop the +/// core rewrote (the core's answer of 2026-09-24): read against the stop's level, its market sale +/// stands short of it, no jump is found, and the moment is the close's as well. +pub(super) fn stop_jump_level(deal: &Deal, exit: &ExitParams) -> Option { + let reason = deal.sell_reason.trim(); + if reason_starts_with(reason, REASON_TRAILING) { + return (exit.trailing_pct != 0.0) + .then(|| stated_peak(reason)) + .flatten() + .map(|peak| trailing_level(peak, deal.buy_price, exit, deal.is_long())); + } + let pct = stop_pct(exit, deal, deal.buy_ms); + (reason_starts_with(reason, REASON_STOP) && pct != 0.0).then(|| { + stated_stop_level(reason).unwrap_or_else(|| stop_level(deal.buy_price, pct, deal.is_long())) + }) +} + +/// The peak a trailing stop's reason prints — `PeakPrice = X;` — when it is a usable price. +pub fn stated_peak(reason: &str) -> Option { + const MARKER: &str = "PeakPrice ="; + let at = reason.find(MARKER)? + MARKER.len(); + let rest = reason[at..].trim_start(); + let len = rest + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .filter(|&len| len > 0)?; + let value: f64 = rest[..len].parse().ok()?; + (value.is_finite() && value > 0.0).then_some(value) +} + /// Where the stop took over an archived line: the first move at or after the buy that stands at /// or past the stop level — the panic sell's first price, or the market order's. fn stop_jump(deal: &Deal, moves: &[(i64, f64)], level: f64) -> Option { @@ -634,6 +684,21 @@ pub const REASONS_LINE: [&str; 3] = ["Auto Price Down", "Sell Level", "SellShot" /// The core's `sellreason` prefix for a position its stop closed. pub const REASON_STOP: &str = "StopLoss"; +/// The core's `sellreason` of a stop — or a trailing stop — sold at market (`UseMarketOrder`). +pub const REASON_MARKET_STOP: &str = "StopLoss Market Sell"; + +/// The core's `sellreason` prefix for a position its trailing stop closed with a limit panic +/// sell; with `UseMarketOrder` the core rewrites it to `StopLoss Market Sell`, like a stop's +/// (the core's answer of 2026-09-24). +pub const REASON_TRAILING: &str = "TrailingStop"; + +/// Whether the core closed the position by a stop or by its trailing stop — the exits the model +/// fires as [`ExitKind::Stop`] and judges by the decision, not the sale. +pub(super) fn is_stop_reason(sell_reason: &str) -> bool { + let reason = sell_reason.trim(); + reason_starts_with(reason, REASON_STOP) || reason_starts_with(reason, REASON_TRAILING) +} + /// Whether the model's exit rule is the one the core's `sellreason` names, so the two prices /// are comparable: the take against "Sell Price", the moving line against the PriceDown / /// SellLevel / SellShot reasons, the stop against "StopLoss …". @@ -643,7 +708,7 @@ fn exit_rule_matches(kind: ExitKind, sell_reason: &str) -> bool { match kind { ExitKind::Take => reason.eq_ignore_ascii_case(REASON_TAKE), ExitKind::Line => REASONS_LINE.iter().any(|r| starts(r)), - ExitKind::Stop => starts(REASON_STOP), + ExitKind::Stop => is_stop_reason(reason), ExitKind::OpenAtWindowEnd => false, } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs index 04e75074..d3b4185e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs @@ -48,7 +48,13 @@ fn the_schema_places_every_field_and_marks_what_the_model_turns() { ), section( "Stops", - &["UseStopLoss", "StopLoss", "UseTrailing", "TrailingPercent"], + &[ + "UseStopLoss", + "StopLoss", + "UseTrailing", + "TrailingPercent", + "TrailingSpread", + ], ), section("Filters", &["MinVolume"]), ]; @@ -76,11 +82,19 @@ fn the_schema_places_every_field_and_marks_what_the_model_turns() { let stops = find(&out, ParamSection::Stops); assert_eq!( - keys(stops)[..4], - ["UseStopLoss", "StopLoss", "UseTrailing", "TrailingPercent"] + keys(stops)[..5], + [ + "UseStopLoss", + "StopLoss", + "UseTrailing", + "TrailingPercent", + "TrailingSpread" + ] ); assert_eq!(stops.rows[0].role, RowRole::Fixed); - assert_eq!(stops.rows[3].role, RowRole::Outside); + // The trailing stop is read as the strategy sets it; its spread is the sale's, not the model's. + assert_eq!(stops.rows[3].role, RowRole::Fixed); + assert_eq!(stops.rows[4].role, RowRole::Outside); // A section outside the grid is not drawn. assert!( diff --git a/locales/analytics.yml b/locales/analytics.yml index 8c08d3fd..627eef95 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1905,9 +1905,9 @@ analytics.ticks.params_title: en: "Parameters" es: "Parámetros" analytics.ticks.assumptions: - ru: "Модель не знает: стакан и очередь на уровне · правила выхода вне модели (трейлинг, лестница стопов) · MShotRepeat* · опора ASK/BID — последний принт своей стороны" - en: "The model does not know: the book and the queue at a level · exit rules outside it (trailing, the stop ladder) · MShotRepeat* · the ASK/BID reference is the last print of its side" - es: "El modelo no conoce: el libro y la cola en un nivel · reglas de salida fuera de él (trailing, escalera de stops) · MShotRepeat* · la referencia ASK/BID es el último print de su lado" + ru: "Модель не знает: стакан и очередь на уровне · правила выхода вне модели (лестница стопов) · MShotRepeat* · опора ASK/BID — последний принт своей стороны" + en: "The model does not know: the book and the queue at a level · exit rules outside it (the stop ladder) · MShotRepeat* · the ASK/BID reference is the last print of its side" + es: "El modelo no conoce: el libro y la cola en un nivel · reglas de salida fuera de él (escalera de stops) · MShotRepeat* · la referencia ASK/BID es el último print de su lado" analytics.ticks.group_entry: ru: "Вход" en: "Entry" From 2794b1b43268c2a4b59285cc560bd3818f275343 Mon Sep 17 00:00:00 2001 From: guyverino Date: Thu, 24 Sep 2026 15:49:59 +0200 Subject: [PATCH 35/51] feat(tuner): divide a short's levels, drop SellShot/SellSpread, warn 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). --- assets/param_deps.toml | 7 +- .../src/db/tuner/ticks/calibrate/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/deals.rs | 1 + .../src/db/tuner/ticks/deltas/mod.rs | 133 ++++++-- crates/moon-core/src/db/tuner/ticks/exit.rs | 103 +++--- .../moon-core/src/db/tuner/ticks/exit/line.rs | 42 +-- .../src/db/tuner/ticks/exit/sell_order.rs | 101 +++--- .../db/tuner/ticks/exit/sell_order/tests.rs | 120 ++++++- .../src/db/tuner/ticks/exit/sell_shot.rs | 118 ------- .../db/tuner/ticks/exit/sell_shot/tests.rs | 45 --- .../src/db/tuner/ticks/exit/sell_spread.rs | 5 - .../src/db/tuner/ticks/exit/stops.rs | 28 +- .../src/db/tuner/ticks/exit/stops/tests.rs | 9 +- .../src/db/tuner/ticks/exit/stops/trailing.rs | 8 +- .../src/db/tuner/ticks/exit/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/mod.rs | 11 +- crates/moon-core/src/db/tuner/ticks/params.rs | 94 +++--- .../src/db/tuner/ticks/record/tests.rs | 1 + .../src/db/tuner/ticks/search/tests.rs | 1 + .../src/db/tuner/ticks/stats/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/tests.rs | 18 +- .../src/db/tuner/ticks/tests/real_data.rs | 46 ++- .../src/db/tuner/ticks/unmodelled.rs | 249 ++++++++++++++ .../src/db/tuner/ticks/unmodelled/tests.rs | 205 ++++++++++++ crates/moon-core/src/db/tuner/ticks/verify.rs | 13 +- crates/moon-core/src/feed/mod.rs | 1 + crates/moon-core/src/feed/strategy_deps.rs | 200 +++++++++++ .../moon-core/src/feed/strategy_deps/tests.rs | 76 +++++ .../src/analytics/tuner/ticks/cfg.rs | 8 +- .../tuner/ticks/delta_summary/tests.rs | 1 + .../src/analytics/tuner/ticks/grid.rs | 17 +- .../src/analytics/tuner/ticks/load.rs | 62 +++- .../src/analytics/tuner/ticks/mod.rs | 1 + .../src/analytics/tuner/ticks/rows/tests.rs | 1 + .../src/analytics/tuner/ticks/sections.rs | 13 +- .../analytics/tuner/ticks/sections/tests.rs | 32 +- .../src/analytics/tuner/ticks/state.rs | 4 + .../src/analytics/tuner/ticks/unmodelled.rs | 314 ++++++++++++++++++ .../analytics/tuner/ticks/unmodelled/tests.rs | 30 ++ .../src/analytics/tuner/ticks/variants.rs | 44 ++- crates/moon-ui-gpui/src/strategies/rules.rs | 203 +++-------- locales/analytics.yml | 38 ++- 42 files changed, 1781 insertions(+), 625 deletions(-) delete mode 100644 crates/moon-core/src/db/tuner/ticks/exit/sell_shot.rs delete mode 100644 crates/moon-core/src/db/tuner/ticks/exit/sell_shot/tests.rs delete mode 100644 crates/moon-core/src/db/tuner/ticks/exit/sell_spread.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/unmodelled.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs create mode 100644 crates/moon-core/src/feed/strategy_deps.rs create mode 100644 crates/moon-core/src/feed/strategy_deps/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled/tests.rs diff --git a/assets/param_deps.toml b/assets/param_deps.toml index f99832b5..ba5f9c1f 100644 --- a/assets/param_deps.toml +++ b/assets/param_deps.toml @@ -215,11 +215,12 @@ "PriceDownPercent" = "AutoSell=YES;PriceDownTimer<>0" "PriceDownRelative" = "AutoSell=YES;PriceDownTimer<>0" "PriceDownTimer" = "HODLmode=NO;AutoSell=YES" +"PriceDownToAllowedDrop" = "AutoSell=YES;PriceDownTimer<>0;PriceDownRelative=YES" "SellByCustomEMA" = "AutoSell=YES" "SellByFilters" = "AutoSell=YES" "SellDelay" = "HODLmode=NO;AutoSell=YES" -"SellEMACheckEnter" = "AutoSell=YES" -"SellEMADelay" = "AutoSell=YES" +"SellEMACheckEnter" = "AutoSell=YES;IgnoreFilters=NO;IgnoreBase=NO;CustomEMA<>" +"SellEMADelay" = "AutoSell=YES;SellByCustomEMA<>" "SellFromAssets" = "AutoSell=YES" "SellLevelAdjust" = "AutoSell=YES;SellLevelDelay<>0;SellLevelTime<>0" "SellLevelAllowedDrop" = "AutoSell=YES;SellLevelDelay<>0;SellLevelTime<>0" @@ -395,7 +396,7 @@ "MShotRepeatWait" = "" "MShotReplaceDelay" = "" "MShotSellAtLastPrice" = "" -"MShotSellPriceAdjust" = "" +"MShotSellPriceAdjust" = "MShotSellAtLastPrice=YES" "MShotSortBy" = "" "MShotSortDesc" = "" "MShotUsePrice" = "" diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs index 31913d94..c8c23b33 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs @@ -29,6 +29,7 @@ fn deal() -> Deal { step_lag_ms: 0.0, stop_anchor: None, delta_track: None, + bars: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index ffdbdb2f..e71fe23a 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -221,6 +221,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { deltas, // Filled by the caller that holds the tape (`deltas::track_for`). delta_track: None, + bars: None, tick: None, pre_spike_ask: None, archived_take: None, diff --git a/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs b/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs index be62f08b..5ad3b9db 100644 --- a/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs @@ -78,6 +78,22 @@ pub const LOOKBACK_MS: i64 = 25 * 60 * MINUTE_MS + CANDLE_MS; /// opens on a hole in the history. const BTC_LOOKBACK_MS: i64 = 4 * 60 * MINUTE_MS; +/// How far before a deal's window its market's bars are kept on the deal (`Deal::bars`) for the +/// sell rules that look back past the tape: SellLevel's `SellLevelTime` is 3 600 s in every live +/// strategy that sets it (1 412 of 1 422, 24.09), 7 200 s at the grid's widest. A look-back past +/// this reads what is there. +pub const PRICE_HISTORY_MS: i64 = 4 * 60 * MINUTE_MS; + +/// What [`track_for`] reads for one deal: the deltas along its window, and its market's bars. +#[derive(Clone, Debug, Default)] +pub struct History { + /// The deltas' track; `None` keeps the report's snapshot (see [`track_for`]). + pub track: Option>, + /// The bars from [`PRICE_HISTORY_MS`] before the window through the tape's end + /// (`Deal::bars`); `None` when the cache holds none. + pub bars: Option>, +} + /// One bar of history: what printed over `[from_ms, to_ms)`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Bar { @@ -355,29 +371,59 @@ pub fn read_bars( from_ms: i64, to_ms: i64, ) -> Vec { - let read = |kind_min: u32| { - let span_ms = i64::from(kind_min) * MINUTE_MS; - cache - .read_range(exchange_key, market, kind_min, from_ms, to_ms) - .unwrap_or_default() - .into_iter() - .filter(|c| c.t_open_ms.is_finite()) - .map(|c| { - let from_ms = c.t_open_ms as i64; - Bar { - from_ms, - to_ms: from_ms + span_ms, - open: f64::from(c.open), - high: f64::from(c.high), - low: f64::from(c.low), - close: f64::from(c.close), - } - }) - .collect::>() - }; - let minutes = read(1); + let (minutes, fives) = read_both(cache, exchange_key, market, from_ms, to_ms); + minute_first(minutes, fives) +} + +/// One kind of a market's bars over `[from_ms, to_ms]` off the kline cache, ascending as the +/// cache gives them. +fn read_kind( + cache: &KlineCache, + exchange_key: &str, + market: &str, + kind_min: u32, + from_ms: i64, + to_ms: i64, +) -> Vec { + let span_ms = i64::from(kind_min) * MINUTE_MS; + cache + .read_range(exchange_key, market, kind_min, from_ms, to_ms) + .unwrap_or_default() + .into_iter() + .filter(|c| c.t_open_ms.is_finite()) + .map(|c| { + let from_ms = c.t_open_ms as i64; + Bar { + from_ms, + to_ms: from_ms + span_ms, + open: f64::from(c.open), + high: f64::from(c.high), + low: f64::from(c.low), + close: f64::from(c.close), + } + }) + .collect() +} + +/// The one-minute and the five-minute bars of a market over `[from_ms, to_ms]`. +fn read_both( + cache: &KlineCache, + exchange_key: &str, + market: &str, + from_ms: i64, + to_ms: i64, +) -> (Vec, Vec) { + ( + read_kind(cache, exchange_key, market, 1, from_ms, to_ms), + read_kind(cache, exchange_key, market, 5, from_ms, to_ms), + ) +} + +/// The minute bars, and the five-minute ones wherever no minute bar lies — bars that do not +/// overlap, as the deltas' series needs them. +fn minute_first(minutes: Vec, fives: Vec) -> Vec { let mut bars = minutes.clone(); - bars.extend(read(5).into_iter().filter(|five| { + bars.extend(fives.into_iter().filter(|five| { !minutes .iter() .any(|m| m.from_ms >= five.from_ms && m.from_ms < five.to_ms) @@ -386,8 +432,20 @@ pub fn read_bars( bars } -/// The track of one deal, from its tape and the kline cache — the one call the tuner's table and -/// the `real_data` bench both make, so what the bench measures is what the table replays. +/// Every bar of both kinds, overlapping — for a rule that takes an extreme over a window +/// (`Deal::bars`), where a five-minute bar holding the minutes the minute bars miss is data, and +/// an overlap changes no extreme. Filtering the five-minute bars by the minute ones, as the series +/// does, lost a whole five-minute bar to a single minute bar inside it, with the rest of its +/// minutes missing. +pub fn all_bars(minutes: &[Bar], fives: &[Bar]) -> Vec { + let mut bars: Vec = minutes.iter().chain(fives).copied().collect(); + bars.sort_by_key(|b| (b.from_ms, b.to_ms)); + bars +} + +/// The track of one deal, from its tape and the kline cache, and the bars it was read off — the +/// one call the tuner's table and the `real_data` bench both make, so what the bench measures is +/// what the table replays. /// /// Args: /// cache: The terminal's kline cache. @@ -406,19 +464,29 @@ pub fn track_for( deal: &Deal, ticks: &[Tick], covered: &Coverage, -) -> Option> { - // Only a track anchored on the report is the core's number (see the module doc): a trade - // without a stamp the tape reaches keeps the snapshot. - let at = snapshot_ms(deal)?; - let (from, to) = covered.hull()?; +) -> History { + let Some((from, to)) = covered.hull() else { + return History::default(); + }; let eval = eval_span(deal); - let coin_bars = read_bars( + let (minutes, fives) = read_both( cache, exchange_key, market, from - LOOKBACK_MS - CANDLE_MS, to, ); + let kept: Vec = all_bars(&minutes, &fives) + .into_iter() + .filter(|b| b.to_ms > eval.0 - PRICE_HISTORY_MS) + .collect(); + let coin_bars = minute_first(minutes, fives); + let bars = (!kept.is_empty()).then(|| Arc::from(kept)); + // Only a track anchored on the report is the core's number (see the module doc): a trade + // without a stamp the tape reaches keeps the snapshot. + let Some(at) = snapshot_ms(deal) else { + return History { track: None, bars }; + }; // A deal on BTC's own market reads BTC off its own bars. let btc_bars = match btc_market { Some(btc) if btc == market => coin_bars @@ -429,7 +497,7 @@ pub fn track_for( Some(btc) => read_bars(cache, exchange_key, btc, eval.0 - BTC_LOOKBACK_MS, eval.1), None => Vec::new(), }; - DeltaTrack::build(TrackInputs { + let track = DeltaTrack::build(TrackInputs { coin_bars: &coin_bars, ticks, covered: covered.spans(), @@ -437,7 +505,8 @@ pub fn track_for( eval, anchor: Some((at, &deal.deltas)), }) - .map(Arc::new) + .map(Arc::new); + History { track, bars } } #[cfg(test)] diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index a083d99d..7a53f7ca 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -3,9 +3,15 @@ //! function of the sell-order rules and the tape. //! //! One file per section of the strategy window, in the window's order: [`stops`], -//! [`sell_order`] (the take, `SellDelay`, `PriceDown*`, `SellLevel*`), [`sell_shot`], -//! [`sell_spread`], [`delta_mods`] — and PumpsDetection's own [`pump_move`]. The step they share — -//! the walk over the tape, the price grid, the latency, the recorded replacements — is [`line`]. +//! [`sell_order`] (the take, `SellDelay`, `PriceDown*`, `SellLevel*`), [`delta_mods`] — and +//! PumpsDetection's own [`pump_move`]. The step they share — the walk over the tape, the price +//! grid, the latency, the recorded replacements — is [`line`]. +//! +//! The "Sell order / SellShot" and "Sell order / SellSpread" sections are not modelled at all (the +//! developer's call, 2026-09-24): SellShot follows the book's side of the market and SellSpread the +//! spread, neither of which the trade tape carries, and 2 live strategies of 1 422 switch either +//! on. A trade under one of them is not judged ([`UnmodelledRule`]). +//! //! A position nothing closed inside the tape is [`ExitKind::OpenAtWindowEnd`]: not a trade, //! whatever the core's exit was. //! @@ -15,8 +21,6 @@ pub mod delta_mods; pub mod line; pub mod pump_move; pub mod sell_order; -pub mod sell_shot; -pub mod sell_spread; pub mod stops; use self::line::{LineWalk, walk, walk_held}; @@ -25,6 +29,36 @@ use super::settings::ModelSettings; use super::{Deal, Exit, Fill}; use crate::feed::types::Tick; +/// A level `pct` per cent off the buy, positive in the PROFIT direction: `buy·(1 + pct/100)` for a +/// long, `buy/(1 + pct/100)` for a short — the short's per cents are counted from the level back +/// to the buy, not the long's product mirrored. The one formula for every level a strategy states +/// in per cent of the buy: the take of every kind, the stop, the trailing's take profit and the +/// `*AllowedDrop` floors. +/// +/// The core developer (2026-09-24, answer 9): every per cent of a short off the buy divides; only +/// the trailing distance, a PriceDown step without `Relative` and `StopAboveLiq` multiply. The +/// site's page on the short says the same ("a take of +50 % at 100 stands at 66.6"). Checked on +/// the data: the stop's `StopLoss fixed: X` lands on the division for 11 099 short stops against +/// 25 for the mirror, and the `PriceDownAllowedDrop` floor of the archived short Exit lines for 67 +/// against 1 (2026-09-24; 215 more sit where the two readings round to one step). +/// +/// A loss of 100 % or more leaves no price: 0 for a long and `f64::INFINITY` for a short, levels +/// no print reaches. +/// +/// Args: +/// buy: The buy the level counts from. +/// pct: The distance, negative on the losing side. +/// long: The trade's side. +pub fn level_off_buy(buy: f64, pct: f64, long: bool) -> f64 { + let keep = 1.0 + pct / 100.0; + match (long, keep <= 0.0) { + (true, true) => 0.0, + (true, false) => buy * keep, + (false, true) => f64::INFINITY, + (false, false) => buy / keep, + } +} + /// Which way the position profits, folding every "above/below the buy" into one sign. Every /// section's rule is written for a long and mirrored for a short through it. #[derive(Clone, Copy)] @@ -33,8 +67,15 @@ struct Side { } impl Side { - /// `pct` per cent over the buy in the PROFIT direction: above for a long, below for a - /// short. + /// `pct` per cent off the buy in the profit direction — [`level_off_buy`]. + fn off_buy(self, buy: f64, pct: f64) -> f64 { + level_off_buy(buy, pct, self.long) + } + + /// `pct` per cent of `base` in the PROFIT direction, the long's product mirrored: above for + /// a long, below for a short. For a distance off a price that is NOT the buy — the high a + /// `SellLevelAdjust` counts from — and for a step that is a share of the price rather than a + /// level. A level off the buy is [`Self::off_buy`]. fn over(self, base: f64, pct: f64) -> f64 { if self.long { base * (1.0 + pct / 100.0) @@ -48,11 +89,6 @@ impl Side { if self.long { a.max(b) } else { a.min(b) } } - /// The nearer of two levels in profit terms. - fn nearer(self, a: f64, b: f64) -> f64 { - if self.long { a.min(b) } else { a.max(b) } - } - /// The extreme print in the profit direction over a run. fn extreme(self, prices: impl Iterator) -> Option { if self.long { @@ -74,19 +110,6 @@ impl Side { .map(|t| f64::from(t.price)), ) } - - /// Distance of `level` from `reference`, per cent, positive in the profit direction. - fn distance_pct(self, reference: f64, level: f64) -> f64 { - if reference <= 0.0 { - return 0.0; - } - let signed = if self.long { - level - reference - } else { - reference - level - }; - signed / reference * 100.0 - } } /// A timer rule's next moment, when it is due by the print at `t_ms`. @@ -158,18 +181,6 @@ pub struct ExitParams { pub sell_level_relative: bool, pub sell_level_allowed_drop_pct: f64, pub sell_level_work_time_s: f64, - // SellShot - pub ignore_sell_shot: bool, - pub sell_shot_distance_pct: f64, - pub sell_shot_corridor_pct: f64, - pub sell_shot_calc_interval_s: f64, - pub sell_shot_raise_wait_s: f64, - pub sell_shot_replace_delay_s: f64, - pub sell_shot_price_down: f64, - pub sell_shot_price_down_delay_s: f64, - pub sell_shot_allowed_up_pct: f64, - pub sell_shot_allowed_down_pct: f64, - pub sell_shot_delay_s: f64, // PumpMove (PumpsDetection) /// `PumpMoveTimer` — seconds after the take before the one pump move; 0 never moves. pub pump_move_timer_s: f64, @@ -246,17 +257,6 @@ impl Default for ExitParams { sell_level_relative: false, sell_level_allowed_drop_pct: 0.0, sell_level_work_time_s: 0.0, - ignore_sell_shot: true, - sell_shot_distance_pct: 0.0, - sell_shot_corridor_pct: 50.0, - sell_shot_calc_interval_s: 0.6, - sell_shot_raise_wait_s: 0.0, - sell_shot_replace_delay_s: 0.0, - sell_shot_price_down: 0.0, - sell_shot_price_down_delay_s: 0.0, - sell_shot_allowed_up_pct: 10.0, - sell_shot_allowed_down_pct: -100.0, - sell_shot_delay_s: 0.0, pump_move_timer_s: 0.0, pump_move_pct: 0.0, stop_loss_pct: 0.0, @@ -280,6 +280,13 @@ impl Default for ExitParams { pub enum UnmodelledRule { /// `UseSecondStop` / `UseStopLoss3` — the stop ladder. StopLadder, + /// `IgnoreSellShot` off with a `SellShotDistance` — the sell kept at a distance from the + /// market's high. + SellShot, + /// `IgnoreSellSpread` off — the sell placed under the spread. + SellSpread, + /// `AutoSell` off — the core places no sell order at all, so there is no line to replay. + NoAutoSell, } /// The exit model over one parameter set. diff --git a/crates/moon-core/src/db/tuner/ticks/exit/line.rs b/crates/moon-core/src/db/tuner/ticks/exit/line.rs index d279c1c3..f3959c85 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/line.rs @@ -1,6 +1,6 @@ //! The moving sell line: where the sell order stood at every moment after the fill, and which //! print crossed it — the step every section's rules share. The rules themselves live with their -//! section of the strategy window: [`super::stops`], [`super::sell_order`], [`super::sell_shot`], +//! section of the strategy window: [`super::stops`], [`super::sell_order`], //! [`super::delta_mods`], and PumpsDetection's own [`super::pump_move`]. //! //! Every rule is written for a long and mirrored for a short by `Side`. A replacement reaches @@ -17,7 +17,6 @@ use super::pump_move::PumpMove; use super::sell_order::{PriceDown, SellLevel, armed_at}; -use super::sell_shot::SellShot; use super::stops::Stops; use super::{ExitParams, Side}; use crate::db::tuner::ticks::{Deal, Exit, ExitKind, Fill, reaches, round_to_step}; @@ -212,8 +211,7 @@ pub fn walk_held( let mut line = Line::new(deal.tick, take, armed_at, params.model.latency_whole_ms()); let mut price_down = PriceDown::new(params, deal, fill, side); let mut pump_move = PumpMove::new(params, fill, side, armed_at); - let mut sell_level = SellLevel::new(params, fill, side); - let mut sell_shot = SellShot::new(params, fill, side); + let mut sell_level = SellLevel::new(params, deal, fill, side); let mut stops = Stops::new(deal, ticks, fill, params, side); let mut last_t = fill.t_ms; @@ -234,24 +232,28 @@ pub fn walk_held( // step due by this print happened BEFORE it, and a step that also reached the book // before it is what this print meets. // - // PriceDown steps, one per due moment, and the pump move, in the order they fell due — - // the pump move first on a tie: each step chains off where the one before it left the - // line. + // PriceDown steps, the pump move and SellLevel's moves, one per due moment, in the + // order they fell due — on a tie the pump move, then PriceDown, then SellLevel: each step + // chains off where the one before it left the line. (SellLevel ran as a pass of its own + // after the others until 2026-09-24, so a PriceDown step due after a SellLevel move went + // first, off the level the move then replaced.) loop { - let pd_due = price_down.due(t_ms); - let pm_due = pump_move.due(t_ms); - if let Some(due) = pm_due.filter(|pm| pd_due.is_none_or(|pd| *pm <= pd)) { - pump_move.step(due, seen, &mut line); - continue; + let due = [ + pump_move.due(t_ms), + price_down.due(t_ms), + sell_level.due(t_ms), + ] + .into_iter() + .enumerate() + .filter_map(|(rule, due)| due.map(|due| (due, rule))) + .min(); + match due { + None => break, + Some((due, 0)) => pump_move.step(due, seen, &mut line), + Some((due, 1)) => price_down.step(due, &mut line), + Some((due, _)) => sell_level.step(due, seen, &mut line), } - let Some(due) = pd_due else { - break; - }; - price_down.step(due, &mut line); } - // SellLevel is not in that race: its moves due by this print all come after it, as a - // pass of their own. - sell_level.catch_up(t_ms, seen, &mut line); line.land(t_ms); // The stops come before the print-driven rule below moves anything. if let Some(exit) = stops.on_print(tick, t_ms, price) { @@ -274,8 +276,6 @@ pub fn walk_held( return line.close(exit); } } - // SellShot: the line follows the market inside its corridor — driven by this print. - sell_shot.on_print(t_ms, seen, &mut line); } let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(last_t); // The fact's own stop past the last print, or the book stop's samples up to the tape's end. diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs index ec7604e0..828f0d45 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs @@ -27,10 +27,14 @@ //! seconds adjusted by `SellLevelAdjust` per cent — of that high, or of its distance to the //! buy when `SellLevelRelative` — at most `SellLevelCount` times, every `SellLevelDelayNext` //! (or `SellLevelDelay`) seconds, inside `SellLevelWorkTime`, never below -//! `SellLevelAllowedDrop` per cent over the buy. +//! `SellLevelAllowedDrop` per cent over the buy. The high is read over the tape AND the +//! market's minute bars (`Deal::bars`): the tape reaches minutes before the order, the look-back +//! an hour — read off the tape alone, the "hour's high" was the high of the run-up, and a +//! variant turning SellLevel on sold at a level the core would never have placed. use super::line::Line; -use super::{ExitModel, ExitParams, Side, due_by}; +use super::{ExitModel, ExitParams, Side, due_by, level_off_buy}; +use crate::db::tuner::ticks::deltas::Bar; use crate::db::tuner::ticks::hook::{KIND_MOONHOOK, hook_take_pct}; use crate::db::tuner::ticks::{Deal, Fill}; use crate::feed::types::Tick; @@ -55,22 +59,15 @@ impl ExitModel<'_> { // Floored at zero: a modifier deep enough to drive the distance negative would put the // TAKE on the losing side of the entry and turn every level the line steps down from // inside out. The rules that legitimately sell below the entry are the moving ones - // (`PriceDownAllowedDrop`, a negative `SellShotDistance`), and they get there by + // (`PriceDownAllowedDrop`), and they get there by // stepping down from the take, not by starting underneath it. let pct = (self.base_take_pct(deal) + self.modifier_pct(deal, fill.t_ms)).max(0.0); - let mshot = take_model_for(&deal.kind); - let mut take = if deal.is_long() { - fill.price * (1.0 + pct / 100.0) - } else if mshot { - // The core divides a short MoonShot's take off the fill (the core developer, - // 2026-09-23): `fill / (1 + SellPrice/100)`. The two archived short takes that - // SellPrice placed and whose price step tells the formulas apart (ONE, BCH_RP) sit - // on it; MoonHook's stored take is rounded too coarsely to tell, and keeps the - // product. - fill.price / (1.0 + pct / 100.0) - } else { - fill.price * (1.0 - pct / 100.0) - }; + // A short's take divides off the fill for every kind (the core developer, 2026-09-23 for + // MoonShot, 2026-09-24 for every per cent of a short off the buy). A MoonHook's take + // carries the delta modifiers, whose live sum the report does not keep, so the archive + // cannot tell the two readings apart on it; the stop and the `PriceDownAllowedDrop` + // floor, which it can, divide. + let mut take = level_off_buy(fill.price, pct, deal.is_long()); if self.params.sell_at_last_price { let pre = deal .pre_spike_ask @@ -268,7 +265,7 @@ impl<'a> PriceDown<'a> { fill, side, next: pd_on.then(|| fill.t_ms + (params.price_down_timer_s * 1000.0) as i64), - floor: side.over(fill.price, params.price_down_allowed_drop_pct), + floor: side.off_buy(fill.price, params.price_down_allowed_drop_pct), delay_ms: step_ms(params.price_down_delay_s, params.model.step_floor_ms), lag_ms: deal.step_lag_ms.max(0.0) as i64, } @@ -287,6 +284,8 @@ impl<'a> PriceDown<'a> { let next = if params.price_down_relative { core - (core - fill.price) * params.price_down_pct / 100.0 } else { + // A share of the price, not a level off the buy: the step multiplies for a short too + // (the core developer, 2026-09-24, answer 9). core - side.over(fill.price, params.price_down_pct) + fill.price }; let next = side.farther(next, self.floor); @@ -319,10 +318,12 @@ pub(super) struct SellLevel<'a> { until: Option, /// `SellLevelAllowedDrop` over the buy. floor: f64, + /// The market's bars (`Deal::bars`), for the part of the look-back the tape does not reach. + bars: &'a [Bar], } impl<'a> SellLevel<'a> { - pub(super) fn new(params: &'a ExitParams, fill: Fill, side: Side) -> Self { + pub(super) fn new(params: &'a ExitParams, deal: &'a Deal, fill: Fill, side: Side) -> Self { let floor_ms = params.model.step_floor_ms; let sl_on = params.sell_level_delay_s != 0.0 && params.sell_level_time_s > 0.0 @@ -348,34 +349,56 @@ impl<'a> SellLevel<'a> { left: params.sell_level_count, until: (params.sell_level_work_time_s > 0.0) .then(|| fill.t_ms + (params.sell_level_work_time_s * 1000.0) as i64), - floor: side.over(fill.price, params.sell_level_allowed_drop_pct), + floor: side.off_buy(fill.price, params.sell_level_allowed_drop_pct), + bars: deal.bars.as_deref().unwrap_or_default(), } } - /// Every move due by the print at `t_ms`: to the high of the look-back, adjusted. + /// The move due by the print at `t_ms`, if any. + pub(super) fn due(&self, t_ms: i64) -> Option { + due_by(self.next, t_ms) + } + + /// The extreme of `[from, due]` in the profit direction: the tape's prints, and every bar that + /// lies inside the look-back and closed by `due` — a bar still open at `due` holds prints + /// from after it, and one that began before `from` holds prints from before the look-back. + fn high(&self, seen: &[Tick], from: i64, due: i64) -> Option { + let side = self.side; + let bars = self + .bars + .iter() + .filter(|b| b.from_ms >= from && b.to_ms <= due) + .map(|b| if side.long { b.high } else { b.low }) + .filter(|p| p.is_finite() && *p > 0.0); + let prints = side.extreme_between(seen, from, due); + side.extreme(prints.into_iter().chain(bars)) + } + + /// The move due at `due`: to the high of the look-back, adjusted. /// /// Args: - /// seen: The prints up to and including the one at `t_ms`. - pub(super) fn catch_up(&mut self, t_ms: i64, seen: &[Tick], line: &mut Line) { + /// seen: The prints up to and including the one the move is due by. + pub(super) fn step(&mut self, due: i64, seen: &[Tick], line: &mut Line) { let (params, fill, side) = (self.params, self.fill, self.side); - while let Some(due) = due_by(self.next, t_ms) { - if self.left == 0 || self.until.is_some_and(|until| due > until) { - self.next = None; - break; - } - let from = due - (params.sell_level_time_s * 1000.0) as i64; - if let Some(high) = side.extreme_between(seen, from, due) { - let next = if params.sell_level_relative { - fill.price + (high - fill.price) * params.sell_level_adjust_pct / 100.0 - } else { - side.over(high, params.sell_level_adjust_pct) - }; - let next = side.farther(next, self.floor); - line.place(due, next); - } - self.left -= 1; - self.next = Some(due + self.every_ms); + if self.left == 0 || self.until.is_some_and(|until| due > until) { + self.next = None; + return; + } + let from = due - (params.sell_level_time_s * 1000.0) as i64; + if let Some(high) = self.high(seen, from, due) { + let next = if params.sell_level_relative { + fill.price + (high - fill.price) * params.sell_level_adjust_pct / 100.0 + } else { + // Off the high, not off the buy — the product, like the trailing distance off its + // peak. No live strategy runs SellLevel (`SellLevelDelay` is absent from all + // 1 422, 2026-09-24), so no archive tells the readings apart. + side.over(high, params.sell_level_adjust_pct) + }; + let next = side.farther(next, self.floor); + line.place(due, next); } + self.left -= 1; + self.next = Some(due + self.every_ms); } } diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs index 930bcc0e..5cba8d63 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs @@ -52,11 +52,11 @@ fn the_archived_ask_sets_the_take_where_the_core_placed_it() { assert!((short_ask - 0.031603 * 0.998).abs() < 1e-12); } -/// A short MoonShot's take is divided off the fill, `fill / (1 + SellPrice/100)`, and the ask's -/// branch placed at `ask / (1 − adjust/100)` when it is the lower (the core developer, -/// 2026-09-23; ONE and BCH_RP on the archive). +/// A short's take is divided off the fill, `fill / (1 + SellPrice/100)`, for every kind, and a +/// MoonShot's ask branch placed at `ask / (1 − adjust/100)` when it is the lower (the core +/// developer, 2026-09-23 and answer 9 of 2026-09-24). #[test] -fn a_short_moonshot_take_divides_off_the_fill() { +fn a_short_take_divides_off_the_fill() { let p = ExitParams { sell_price_pct: 1.0, ..params() @@ -72,11 +72,53 @@ fn a_short_moonshot_take_divides_off_the_fill() { d.pre_spike_ask = Some(97.0); let take = ExitModel::new(&lifted).take_level(&d, &[], fill()); assert!((take - 97.0 / 0.99).abs() < 1e-9, "{take}"); - // A MoonHook short keeps the product: its stored take cannot tell the two apart. + // A MoonHook short divides too — not the product's 99 — and so does its own take rule. let mut hook = deal(true); hook.kind = "MoonHook".into(); let take = ExitModel::new(&p).take_level(&hook, &[], fill()); - assert!((take - 99.0).abs() < 1e-9, "{take}"); + assert!((take - 100.0 / 1.01).abs() < 1e-9, "{take}"); + hook.hook_depth_pct = Some(4.0); + let rule = ExitParams { + hook_sell_level_pct: 50.0, + ..p.clone() + }; + let take = ExitModel::new(&rule).take_level(&hook, &[], fill()); + assert!((take - 100.0 / 1.02).abs() < 1e-9, "{take}"); + // A long's take is the product, as ever. + let take = ExitModel::new(&p).take_level(&deal(false), &[], fill()); + assert!((take - 101.0).abs() < 1e-9, "{take}"); +} + +/// A short's `PriceDownAllowedDrop` floor is divided off the buy — `buy / (1 + drop/100)` — as +/// the archived short lines stop on it (67 against 1 for the product, 2026-09-24); the step +/// without `Relative` stays a share of the price. +#[test] +fn a_short_price_down_floor_divides_off_the_buy() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 100.0, + price_down_delay_s: 1.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.5, + ..params() + }; + // Take 99; a relative step of 100 % goes to the buy, the floor stops it at 100 / 1.005 — + // not the product's 99.5. + let ticks = tape(&[(1_500, 99.6), (2_500, 99.6)]); + let w = walk(&deal(true), &ticks, fill(), 99.0, &p); + assert!( + (w.points[1].price - 100.0 / 1.005).abs() < 1e-9, + "{:?}", + w.points + ); + assert_eq!(w.points.len(), 2, "no step past the floor: {:?}", w.points); + let absolute = ExitParams { + price_down_pct: 0.2, + price_down_relative: false, + ..p + }; + let w = walk(&deal(true), &ticks, fill(), 99.0, &absolute); + assert!((w.points[1].price - 99.2).abs() < 1e-9, "{:?}", w.points); } // ---- PriceDown ------------------------------------------------------------------------------- @@ -179,6 +221,72 @@ fn sell_level_moves_to_the_look_back_high_adjusted() { assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Line, 3_000)); } +/// The look-back reads the market's bars where the tape does not reach — BROCCOLI714 24.09: an +/// hour's look-back read off a tape of minutes put the sell at the run-up's high — and only the +/// bars inside it that closed by the move: one still open holds prints from after it, one that +/// began before the look-back holds prints from before it. +#[test] +fn sell_level_reads_the_bars_before_the_tape() { + let p = ExitParams { + sell_level_delay_s: 2.0, + sell_level_time_s: 3_600.0, + sell_level_count: 1, + sell_level_adjust_pct: 0.0, + ..params() + }; + let bar = |from_ms: i64, to_ms: i64, high: f64| Bar { + from_ms, + to_ms, + open: 100.0, + high, + low: 99.0, + close: 100.0, + }; + let mut d = deal(false); + d.bars = Some( + vec![ + // Began before the look-back (2 000 − 3 600 000): not read. + bar(-3_660_000, -3_540_000, 130.0), + // Inside it and closed: the high the core saw. + bar(-600_000, -540_000, 110.0), + // Still open at the move: not read. + bar(0, 60_000, 125.0), + ] + .into(), + ); + let ticks = tape(&[(500, 103.0), (1_000, 100.5), (2_500, 100.5)]); + let w = walk(&d, &ticks, fill(), 105.0, &p); + assert!((w.points[1].price - 110.0).abs() < 1e-9, "{:?}", w.points); + // Without the bars the tape's own high is all there is. + let w = walk(&deal(false), &ticks, fill(), 105.0, &p); + assert!((w.points[1].price - 103.0).abs() < 1e-9, "{:?}", w.points); +} + +/// SellLevel runs in the timers' race: a move due before a PriceDown step goes first, and the step +/// chains off the level it left — not the other way round because both fell due by one print. +#[test] +fn sell_level_and_price_down_go_in_the_order_they_fall_due() { + let p = ExitParams { + price_down_timer_s: 3.0, + price_down_pct: 50.0, + price_down_delay_s: 10.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.0, + sell_level_delay_s: 2.9, + sell_level_time_s: 10.0, + sell_level_count: 1, + sell_level_adjust_pct: 0.0, + ..params() + }; + // Both are due by the print at 3 500: SellLevel at 2 900 from the take 105 to the high 104, + // then PriceDown at 3 000 halfway from 104 to the buy — not PriceDown off 105 to 102.5 first. + let ticks = tape(&[(500, 104.0), (3_500, 100.5)]); + let w = walk(&deal(false), &ticks, fill(), 105.0, &p); + let levels: Vec<(i64, f64)> = w.points.iter().map(|pt| (pt.t_ms, pt.price)).collect(); + assert_eq!(levels[1], (2_900, 104.0), "{levels:?}"); + assert!((levels[2].1 - 102.0).abs() < 1e-9, "{levels:?}"); +} + #[test] fn sell_level_relative_takes_a_share_of_the_distance_to_the_buy() { let p = ExitParams { diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_shot.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_shot.rs deleted file mode 100644 index cbae40fc..00000000 --- a/crates/moon-core/src/db/tuner/ticks/exit/sell_shot.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! The strategy window's "Sell order / SellShot" section. -//! -//! **SellShot** (`IgnoreSellShot` off, `SellShotDistance` non-zero) — after `SellShotDelay` the -//! sell keeps `SellShotDistance` per cent off the highest print of the last -//! `SellShotCalcInterval` seconds, re-placed when its distance leaves the corridor -//! `Distance · (1 ± Corridor/100)`: after `SellShotRaiseWait` when moving away from the buy, -//! after `SellShotReplaceDelay` when moving toward it; `SellShotPriceDown` narrows the distance -//! by that much per second past `SellShotPriceDownDelay`; the line stays between -//! `SellShotAllowedDown` and `SellShotAllowedUp` per cent over the buy. -//! -//! From the Moonbot FAQ (`SellShot*` answers). Not checked on the tape: on 2026-09-20 -//! `IgnoreSellShot` was on in all live strategies but two. - -use super::line::Line; -use super::{ExitParams, Side}; -use crate::db::tuner::ticks::Fill; -use crate::db::tuner::ticks::mshot::FAST_ALGO_WINDOW_MS; -use crate::feed::types::Tick; - -/// SellShot's window, bounds and the corridor breach it is waiting out. -pub(super) struct SellShot<'a> { - params: &'a ExitParams, - fill: Fill, - side: Side, - /// Whether the strategy switched the rule on. - on: bool, - /// The end of `SellShotDelay`. - from: i64, - /// The calculation window. - calc_ms: i64, - /// `SellShotAllowedDown` over the buy. - low: f64, - /// `SellShotAllowedUp` over the buy. - high: f64, - /// `(kind, since)`: which way the line is out of the corridor and since when. - breach: Option<(bool, i64)>, -} - -impl<'a> SellShot<'a> { - pub(super) fn new(params: &'a ExitParams, fill: Fill, side: Side) -> Self { - Self { - params, - fill, - side, - on: !params.ignore_sell_shot && params.sell_shot_distance_pct != 0.0, - from: fill.t_ms + (params.sell_shot_delay_s.max(0.0) * 1000.0) as i64, - // The core's own floor on the SellShot calculation window — the same 100 ms its fast - // algorithm reads, but a rule of the sell, not the entry's re-place window - // (`ModelSettings::replace_window_ms`), and not a setting of the model. - calc_ms: ((params.sell_shot_calc_interval_s.max(0.0) * 1000.0) as i64) - .max(FAST_ALGO_WINDOW_MS), - low: side.over(fill.price, params.sell_shot_allowed_down_pct), - high: side.over(fill.price, params.sell_shot_allowed_up_pct), - breach: None, - } - } - - /// The line follows the market inside its corridor — driven by the print at `t_ms`. - /// - /// Args: - /// seen: The prints up to and including the one at `t_ms`. - pub(super) fn on_print(&mut self, t_ms: i64, seen: &[Tick], line: &mut Line) { - if !self.on || t_ms < self.from { - return; - } - let (params, fill, side) = (self.params, self.fill, self.side); - let from = t_ms - self.calc_ms; - let reference = side.extreme( - seen.iter() - .filter(|t| (t.time_ms as i64) >= from && t.price > 0.0) - .map(|t| f64::from(t.price)), - ); - let Some(reference) = reference else { - return; - }; - let elapsed_s = (t_ms - fill.t_ms) as f64 / 1000.0; - let mut distance = params.sell_shot_distance_pct; - if params.sell_shot_price_down < 0.0 { - let past = (elapsed_s - params.sell_shot_price_down_delay_s).max(0.0); - distance -= params.sell_shot_price_down.abs() * past; - } - let corridor = distance.abs() * params.sell_shot_corridor_pct / 100.0; - let d = side.distance_pct(reference, line.core()); - let out = if d > distance + corridor { - Some(false) // too far from the market: move toward the buy - } else if d < distance - corridor { - Some(true) // too close: move away from the buy - } else { - None - }; - match out { - None => self.breach = None, - Some(away) => { - let since = match self.breach { - Some((seen, since)) if seen == away => since, - _ => { - self.breach = Some((away, t_ms)); - t_ms - } - }; - let wait_ms = if away { - (params.sell_shot_raise_wait_s * 1000.0) as i64 - } else { - (params.sell_shot_replace_delay_s * 1000.0) as i64 - }; - if t_ms - since >= wait_ms { - let next = side.over(reference, distance); - let next = side.nearer(side.farther(next, self.low), self.high); - line.place(t_ms, next); - self.breach = None; - } - } - } - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_shot/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_shot/tests.rs deleted file mode 100644 index 8964cf86..00000000 --- a/crates/moon-core/src/db/tuner/ticks/exit/sell_shot/tests.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! The SellShot section on synthetic tapes. - -use super::*; -use crate::db::tuner::ticks::ExitKind; -use crate::db::tuner::ticks::exit::line::walk; -use crate::db::tuner::ticks::exit::tests::{deal, fill, params, tape}; - -// ---- SellShot --------------------------------------------------------------------------------- - -#[test] -fn sell_shot_follows_the_market_inside_its_corridor() { - // Distance 1 %, corridor 50 %: the line stays while 0.5-1.5 % off the reference. The - // take at 101 is 1 % off 100; a rise to 100.8 leaves it 0.2 % off -> too close -> after - // the raise wait (0) it moves away to 101.808. - let p = ExitParams { - ignore_sell_shot: false, - sell_shot_distance_pct: 1.0, - sell_shot_corridor_pct: 50.0, - sell_shot_calc_interval_s: 0.1, - sell_shot_allowed_up_pct: 10.0, - sell_shot_allowed_down_pct: -1.0, - ..params() - }; - let ticks = tape(&[(500, 100.0), (1_000, 100.8), (1_500, 101.5)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - assert!((w.points[1].price - 101.808).abs() < 1e-4, "{:?}", w.points); - // 101.5 stays under the moved line, and the tape ends before the report's close. - assert_eq!(w.exit.kind, ExitKind::OpenAtWindowEnd); -} - -#[test] -fn sell_shot_is_capped_by_allowed_up() { - let p = ExitParams { - ignore_sell_shot: false, - sell_shot_distance_pct: 1.0, - sell_shot_corridor_pct: 50.0, - sell_shot_calc_interval_s: 0.1, - sell_shot_allowed_up_pct: 0.5, - sell_shot_allowed_down_pct: -1.0, - ..params() - }; - let ticks = tape(&[(500, 100.0), (1_000, 100.8)]); - let w = walk(&deal(false), &ticks, fill(), 101.0, &p); - assert!((w.points[1].price - 100.5).abs() < 1e-4, "{:?}", w.points); -} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_spread.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_spread.rs deleted file mode 100644 index 92935d2b..00000000 --- a/crates/moon-core/src/db/tuner/ticks/exit/sell_spread.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! The strategy window's "Sell order / SellSpread" section — not modelled yet. -//! -//! No live strategy switched it on (2026-09-23), so there is no trade to read the core's -//! behaviour off and none the model would judge differently. The section's fields are -//! `Outside` rows of the grid until its rule lands here. diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops.rs index aef1a70f..f3bcf76e 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/stops.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops.rs @@ -72,30 +72,6 @@ pub fn stop_pct(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { adjusted } -/// The stop's price: `pct` per cent ([`stop_pct`]) off the buy — `buy·(1 + pct/100)` for a long, -/// `buy/(1 + pct/100)` for a short, not the long's product mirrored. The core prints the level -/// into its reason (`StopLoss fixed: X`): over the report (2026-09-24) the division lands on it -/// for 11 099 short stops against 25 for the mirror, the product for 20 930 long ones against 12 -/// — the adjusted distance of `StopLossModifier` included. At `−2.5 %` the two short readings -/// part by 0.06 % of the price. -/// -/// A loss of 100 % or more leaves no price to stop at: 0 for a long and `f64::INFINITY` for a -/// short, levels no print reaches. -/// -/// Args: -/// buy: The buy the stop counts from. -/// pct: The stop distance, negative on the losing side. -/// long: The trade's side. -pub fn stop_level(buy: f64, pct: f64, long: bool) -> f64 { - let keep = 1.0 + pct / 100.0; - match (long, keep <= 0.0) { - (true, true) => 0.0, - (true, false) => buy * keep, - (false, true) => f64::INFINITY, - (false, false) => buy / keep, - } -} - /// The weight of a new ticker price in the average the non-fast stop watches, when the core /// keeps one: `avg = (avg·(N − 1) + bid) / N`, a weight of `1/N`, for a LONG at `StopLossEMA` /// 3, 5 or 10 only. Any other value — 7 included — and every short watch the bare price (the @@ -338,13 +314,13 @@ impl Stops { // The ADJUSTED distance decides both whether there is a stop and where it stands — // reading the raw `stop_loss_pct` for the first and the adjusted one for the second would // arm a stop the adjustment had cancelled, at the fill price itself, where the next print - // fires it. [`stop_level`] puts the distance on the trade's side, so only the distance is + // fires it. `level_off_buy` puts the distance on the trade's side, so only the distance is // adjusted here; the level is NOT snapped to the price grid, unlike every level that // reaches the exchange — // a stop is the core's own trigger for a market sell, and nothing about it is ever placed. let stop = stop_pct(params, deal, fill.t_ms); let stop_on = stop != 0.0; - let level = stop_level(fill.price, stop, side.long); + let level = side.off_buy(fill.price, stop); let stop_from = fill.t_ms + (params.stop_loss_delay_s.max(0.0) * 1000.0) as i64; // What the fact proves about the stop when this walk runs the trade's own // (`record::StopAnchor`): it fired when the core's did, at the price the core sold at, and diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs index 2dcd4d86..709964d1 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs @@ -2,6 +2,7 @@ //! how the verdict judges each. use super::*; +use crate::db::tuner::ticks::exit::level_off_buy; use crate::db::tuner::ticks::exit::line::walk; use crate::db::tuner::ticks::exit::tests::{deal, fill, params, tape, tick}; use crate::db::tuner::ticks::{EntryParams, verify}; @@ -36,12 +37,12 @@ fn a_short_stop_divides_the_buy() { let inside = tape(&[(1_000, 102.53), (2_000, 102.57)]); let w = walk(&deal(true), &inside, fill(), 99.0, &p); assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 2_000), "{w:?}"); - assert!((stop_level(100.0, -2.5, false) - 100.0 / 0.975).abs() < 1e-9); - assert!((stop_level(100.0, -2.5, true) - 97.5).abs() < 1e-9); + assert!((level_off_buy(100.0, -2.5, false) - 100.0 / 0.975).abs() < 1e-9); + assert!((level_off_buy(100.0, -2.5, true) - 97.5).abs() < 1e-9); // A stop on the profit side of a short sits below the buy, by the same division. - assert!((stop_level(100.0, 0.4, false) - 100.0 / 1.004).abs() < 1e-9); + assert!((level_off_buy(100.0, 0.4, false) - 100.0 / 1.004).abs() < 1e-9); // A loss of 100 % or more leaves a short no finite price to stop at. - assert_eq!(stop_level(100.0, -100.0, false), f64::INFINITY); + assert_eq!(level_off_buy(100.0, -100.0, false), f64::INFINITY); } fn sold(t_ms: i64, price: f64) -> Tick { diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing.rs index a9e976b8..9b622036 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/trailing.rs @@ -23,7 +23,7 @@ //! ticker's clock ([`super::TICKER_PERIOD_MS`]). use super::super::ExitParams; -use super::stop_level; +use super::super::level_off_buy; use crate::db::tuner::ticks::{Exit, ExitKind}; use crate::feed::types::{Side as TickSide, Tick}; @@ -46,7 +46,7 @@ pub fn trailing_level(peak: f64, buy: f64, params: &ExitParams, long: bool) -> f -params.trailing_pct.abs(), params .trailing_take_profit_pct - .map(|tp| stop_level(buy, tp, long)), + .map(|tp| level_off_buy(buy, tp, long)), long, ) } @@ -124,10 +124,10 @@ impl Trailing { // stop's own conversion, boundary included. let take = params .trailing_take_profit_pct - .map(|tp| stop_level(buy, tp, long)); + .map(|tp| level_off_buy(buy, tp, long)); let activation = params .trailing_take_profit_pct - .map(|tp| stop_level(buy, tp + pct.abs(), long)); + .map(|tp| level_off_buy(buy, tp + pct.abs(), long)); let period_ms = params.model.ticker_period_ms.max(1); let anchor = fill_ms + period_ms; let back = (anchor - first_ms).max(0) / period_ms; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/tests.rs index 36fd6506..d66c8738 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/tests.rs @@ -44,6 +44,7 @@ pub(super) fn deal(short: bool) -> Deal { step_lag_ms: 0.0, stop_anchor: None, delta_track: None, + bars: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 3b06c0fe..0e89b7ea 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -19,7 +19,7 @@ //! into the column caption; the model does not hide it. //! //! Sources of the mechanics: the Moonbot FAQ (`data/faqru.tsv`, "Какие есть специфические -//! параметры у стратегии MoonShot" and the `PriceDown*` / `SellLevel*` / `SellShot*` answers), +//! параметры у стратегии MoonShot" and the `PriceDown*` / `SellLevel*` answers), //! checked against the live `strategies.sqlite` field names on 2026-09-20. use crate::feed::types::Tick; @@ -38,6 +38,7 @@ pub mod scope; pub mod search; pub mod settings; pub mod stats; +pub mod unmodelled; pub mod verify; pub use deals::{DealsRead, read_deals}; @@ -219,6 +220,12 @@ pub struct Deal { /// ([`deltas::track_for`]); filled by the caller that holds the tape, before the other model /// inputs (the stop anchor reads the stop through it). `None` keeps the snapshot everywhere. pub delta_track: Option>, + /// The market's price bars — one-minute klines, five-minute ones where no minute bar lies — + /// from [`deltas::PRICE_HISTORY_MS`] before the window through the tape's end + /// ([`deltas::track_for`]): what a rule that looks back further than the tape reads, as + /// SellLevel's `SellLevelTime` does. `None` without a kline cache; the rule then reads the tape + /// alone. + pub bars: Option>, /// Price step of the market, when the caller could resolve it (the live catalog, or /// [`infer_tick`] over the window). `None` disables the step-bound rules and rounds nothing. pub tick: Option, @@ -387,7 +394,7 @@ pub struct Fill { pub enum ExitKind { /// A print reached the take-profit level. Take, - /// A print crossed the moving sell line (PriceDown / SellLevel / SellShot). + /// A print crossed the moving sell line (PriceDown / SellLevel / PumpMove). Line, /// The stop-loss level was crossed: a market exit at the print. Stop, diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index e7dd6af1..1fb2e91e 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -61,6 +61,13 @@ impl ParamSection { ParamSection::DeltaModifiers => "Delta Modifiers", } } + + /// Whether the model has the section's rules at all. SellShot and SellSpread it does not (the + /// developer's call, 2026-09-24): the grid draws them without knobs and says so, and a trade + /// of a strategy that switches one on is not judged (`exit::UnmodelledRule`). + pub fn modelled(self) -> bool { + !matches!(self, ParamSection::SellShot | ParamSection::SellSpread) + } } /// How a parameter is typed and, for the search, which values it may take. @@ -157,15 +164,6 @@ const GRID_DROP: &[f64] = &[ const GRID_SL_DELAY_S: &[f64] = &[0.0, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0]; const GRID_SL_TIME_S: &[f64] = &[0.0, 60.0, 300.0, 900.0, 1800.0, 3600.0, 7200.0]; const GRID_SL_COUNT: &[f64] = &[0.0, 1.0, 2.0, 3.0, 5.0, 10.0]; -const GRID_SS_DISTANCE: &[f64] = &[ - 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0, 1.25, 1.5, 2.0, -]; -const GRID_SS_CORRIDOR: &[f64] = &[10.0, 25.0, 50.0, 75.0, 90.0]; -const GRID_SS_INTERVAL_S: &[f64] = &[0.2, 0.4, 0.6, 1.0, 2.0, 5.0, 10.0, 25.0]; -const GRID_SS_WAIT_S: &[f64] = &[0.0, 0.1, 0.2, 0.5, 1.0, 2.0]; -const GRID_SS_BOUND: &[f64] = &[ - -1.0, -0.5, -0.2, -0.1, 0.0, 0.2, 0.4, 0.5, 1.0, 2.0, 5.0, 10.0, -]; /// Live values run to −15 (29 of 1 869 strategies sit outside the old −10 floor). const GRID_STOP: &[f64] = &[ -15.0, -12.0, -10.0, -7.0, -5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.75, -0.5, -0.3, -0.2, @@ -396,23 +394,6 @@ pub const TICK_PARAMS: &[TickParam] = &[ exit_bool("SellLevelRelative", ParamSection::SellOrder), exit_num("SellLevelAllowedDrop", ParamSection::SellOrder, GRID_DROP), exit_num("SellLevelWorkTime", ParamSection::SellOrder, GRID_SL_TIME_S), - exit_bool("IgnoreSellShot", ParamSection::SellShot), - exit_num("SellShotDistance", ParamSection::SellShot, GRID_SS_DISTANCE), - exit_num("SellShotCorridor", ParamSection::SellShot, GRID_SS_CORRIDOR), - exit_num( - "SellShotCalcInterval", - ParamSection::SellShot, - GRID_SS_INTERVAL_S, - ), - exit_num("SellShotRaiseWait", ParamSection::SellShot, GRID_SS_WAIT_S), - exit_num( - "SellShotReplaceDelay", - ParamSection::SellShot, - GRID_SS_WAIT_S, - ), - exit_num("SellShotAllowedUp", ParamSection::SellShot, GRID_SS_BOUND), - exit_num("SellShotAllowedDown", ParamSection::SellShot, GRID_SS_BOUND), - exit_num("SellShotDelay", ParamSection::SellShot, GRID_SS_WAIT_S), exit_num("StopLoss", ParamSection::Stops, GRID_STOP), exit_num("StopLossDelay", ParamSection::Stops, GRID_STOP_DELAY_S), ]; @@ -466,11 +447,6 @@ pub fn params_for<'k>( /// something the search should turn. const MODEL_ONLY_KEYS: &[&str] = &[ "HookSellFixed", - // Read by `exit_params` and acted on by the SellShot walk, never a grid knob — and absent - // from both lists until 2026-09-22, so the decay of the sell-shot distance has been running - // on its fallback since the axis was written. - "SellShotPriceDown", - "SellShotPriceDownDelay", "SellModifier", "MaxModifier", "StopLossModifier", @@ -484,10 +460,6 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "TrailingEMA", "UseTakeProfit", "TakeProfit", - // The switches of the stop ladder, which the model does not have: a trade under one is not - // modelled (`exit::UnmodelledRule`). - "UseSecondStop", - "UseStopLoss3", // PumpsDetection's one sell move (see `exit::pump_move::PUMP_MOVE_LAG_MS`); `PumpMovePersent` is the // core's own spelling of the field. "PumpMoveTimer", @@ -511,19 +483,35 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "MShotAdd5sDelta", ]; +/// The switches of the sell rules the model does NOT have — read only to tell that one is on, +/// which keeps the trade out of the verdict and the search ([`unmodelled_rule`]). Not +/// [`MODEL_ONLY_KEYS`]: the model acts on none of them, and the grid draws them as outside it. +const RULE_SWITCH_KEYS: &[&str] = &[ + // No sell order at all. + "AutoSell", + // The stop ladder. + "UseSecondStop", + "UseStopLoss3", + // SellShot is on only with a distance to keep. + "IgnoreSellShot", + "SellShotDistance", + "IgnoreSellSpread", +]; + /// Whether the models read `key` from the strategy without the grid offering it as a knob — /// the grid draws such a field as fixed rather than as outside the model. pub fn is_model_only(key: &str) -> bool { MODEL_ONLY_KEYS.contains(&key) } -/// Every field name the models read — [`TICK_PARAMS`] plus [`MODEL_ONLY_KEYS`] — for a -/// `strategy_current_values` read. +/// Every field name the models read — [`TICK_PARAMS`], [`MODEL_ONLY_KEYS`] and the switches of +/// the rules they do not have ([`RULE_SWITCH_KEYS`]) — for a `strategy_current_values` read. pub fn param_keys() -> Vec { TICK_PARAMS .iter() .map(|p| p.key) .chain(MODEL_ONLY_KEYS.iter().copied()) + .chain(RULE_SWITCH_KEYS.iter().copied()) .map(str::to_string) .collect() } @@ -665,18 +653,6 @@ pub fn exit_params(v: &StrategyValues<'_>, model: ModelSettings) -> ExitParams { sell_level_allowed_drop_pct: v .num("SellLevelAllowedDrop", base.sell_level_allowed_drop_pct), sell_level_work_time_s: v.num("SellLevelWorkTime", base.sell_level_work_time_s), - ignore_sell_shot: v.bool("IgnoreSellShot", base.ignore_sell_shot), - sell_shot_distance_pct: v.num("SellShotDistance", base.sell_shot_distance_pct), - sell_shot_corridor_pct: v.num("SellShotCorridor", base.sell_shot_corridor_pct), - sell_shot_calc_interval_s: v.num("SellShotCalcInterval", base.sell_shot_calc_interval_s), - sell_shot_raise_wait_s: v.num("SellShotRaiseWait", base.sell_shot_raise_wait_s), - sell_shot_replace_delay_s: v.num("SellShotReplaceDelay", base.sell_shot_replace_delay_s), - sell_shot_price_down: v.num("SellShotPriceDown", base.sell_shot_price_down), - sell_shot_price_down_delay_s: v - .num("SellShotPriceDownDelay", base.sell_shot_price_down_delay_s), - sell_shot_allowed_up_pct: v.num("SellShotAllowedUp", base.sell_shot_allowed_up_pct), - sell_shot_allowed_down_pct: v.num("SellShotAllowedDown", base.sell_shot_allowed_down_pct), - sell_shot_delay_s: v.num("SellShotDelay", base.sell_shot_delay_s), pump_move_timer_s: v.num("PumpMoveTimer", base.pump_move_timer_s), pump_move_pct: v.num("PumpMovePersent", base.pump_move_pct), // `StopLoss` means nothing with `UseStopLoss` off (param_deps.toml: every stop field @@ -714,12 +690,22 @@ pub fn exit_params(v: &StrategyValues<'_>, model: ModelSettings) -> ExitParams { /// /// Read by the switch, never by its fields: the fields stay in a strategy's dump with the switch /// off (`assets/param_deps.toml`). On this machine's reports (2026-09-23) the trailing stop was -/// on for the strategies of 44 trades of 2 042 and the stop ladder for 2; `SellSpread` and the EMA exit -/// were on for none, and are left out until a strategy turns them on. The trailing stop is -/// modelled since 2026-09-24 (`exit::stops::trailing`). -fn unmodelled_rule(v: &StrategyValues<'_>) -> Option { - if v.bool("UseSecondStop", false) || v.bool("UseStopLoss3", false) { +/// on for the strategies of 44 trades of 2 042 and the stop ladder for 2; the trailing stop is +/// modelled since 2026-09-24 (`exit::stops::trailing`). SellShot and SellSpread are not modelled +/// at all (the developer's call, 2026-09-24); each is on for 2 live strategies of 1 422 (24.09), +/// SellShot only where a distance is set (the walk kept it off at a zero one). `AutoSell` off +/// places no sell order at all (no live strategy, 24.09). The EMA exit and +/// the rest of the sell fields the model does not have are in `unmodelled`, which warns about +/// them; this is the set that takes a trade out of the verdict. +pub(super) fn unmodelled_rule(v: &StrategyValues<'_>) -> Option { + if !v.bool("AutoSell", true) { + Some(UnmodelledRule::NoAutoSell) + } else if v.bool("UseSecondStop", false) || v.bool("UseStopLoss3", false) { Some(UnmodelledRule::StopLadder) + } else if !v.bool("IgnoreSellShot", true) && v.num("SellShotDistance", 0.0) != 0.0 { + Some(UnmodelledRule::SellShot) + } else if !v.bool("IgnoreSellSpread", true) { + Some(UnmodelledRule::SellSpread) } else { None } diff --git a/crates/moon-core/src/db/tuner/ticks/record/tests.rs b/crates/moon-core/src/db/tuner/ticks/record/tests.rs index 05e08e3f..9d1bb27a 100644 --- a/crates/moon-core/src/db/tuner/ticks/record/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -54,6 +54,7 @@ fn stopped() -> Deal { step_lag_ms: 0.0, stop_anchor: None, delta_track: None, + bars: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index af4839be..c6959a0a 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -44,6 +44,7 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { step_lag_ms: 0.0, stop_anchor: None, delta_track: None, + bars: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index 61827d3f..2a9da4cd 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -27,6 +27,7 @@ fn deal(pnl: f64, spent: f64) -> Deal { step_lag_ms: 0.0, stop_anchor: None, delta_track: None, + bars: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 31158212..f0697750 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -54,6 +54,7 @@ pub(super) fn deal() -> Deal { step_lag_ms: 0.0, stop_anchor: None, delta_track: None, + bars: None, own_entry: None, buy_set_ms: None, corridor: None, @@ -1433,7 +1434,14 @@ fn the_descriptor_keys_every_field_the_builders_read_and_splits_the_groups() { !exit_any.contains(&"MShotSellAtLastPrice"), "a MoonShot-only field" ); - assert!(exit_any.contains(&"StopLoss") && exit_any.contains(&"SellShotDistance")); + assert!(exit_any.contains(&"StopLoss")); + // SellShot and SellSpread are not modelled: no knob of theirs (`ParamSection::modelled`). + assert!( + TICK_PARAMS + .iter() + .all(|p| p.section.modelled() && !p.key.starts_with("SellShot")), + "a knob of an unmodelled section" + ); assert!(entry_model_for("MoonShot") && !entry_model_for("Spread")); } @@ -1460,6 +1468,7 @@ fn hook_deal() -> Deal { step_lag_ms: 0.0, stop_anchor: None, delta_track: None, + bars: None, own_entry: None, buy_set_ms: None, corridor: None, @@ -1491,7 +1500,8 @@ fn a_hook_takes_a_share_of_its_detect_depth() { assert!((take - 104.0).abs() < 1e-9, "{take}"); } -/// A short hook sells below the entry, by the same share. +/// A short hook sells below the entry, by the same share divided off the fill — every per cent of +/// a short off the buy divides (`exit::level_off_buy`). #[test] fn a_short_hook_takes_below_the_entry() { let params = ExitParams { @@ -1507,7 +1517,7 @@ fn a_short_hook_takes_below_the_entry() { price: 100.0, }; let take = ExitModel::new(¶ms).take_level(&d, &[], fill); - assert!((take - 98.0).abs() < 1e-9, "{take}"); + assert!((take - 100.0 / 1.02).abs() < 1e-9, "{take}"); } /// Without a depth (or without a level) the rule cannot be computed — the model still needs a @@ -1873,7 +1883,7 @@ fn a_cancelled_stop_does_not_fire_at_the_entry() { ); } -/// A short's stop sits ABOVE the entry, the adjusted distance included (`stops::stop_level` +/// A short's stop sits ABOVE the entry, the adjusted distance included (`exit::level_off_buy` /// divides the fill by it). #[test] fn a_short_stop_sits_above_with_the_modifier() { diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 65d33123..4f566f58 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -12,7 +12,9 @@ //! analysis outside; `MOON_TICKS_LATENCY_MS` replays with another latency and //! `MOON_TICKS_LATENCY_BASE_MS` with that plus each core's archived round trip, the entry's only //! unless `MOON_TICKS_LATENCY_EXIT` is set; `MOON_TICKS_PATH_DEBUG` prints each order's modelled -//! and archived path); the application never moves its data root on a variable. +//! and archived path; `MOON_TICKS_VARIANT="Key=value,…"` replays each deal under those values laid +//! over its own and prints the line it walked); the application never moves its data root on a +//! variable. use std::collections::HashMap; use std::path::PathBuf; @@ -100,7 +102,7 @@ fn dump_deal( // buy, the level the core printed into the reason, and the activation — the archive's jump // past that level. let stop = super::super::exit::stops::stop_pct(exit, deal, deal.buy_ms); - let level = super::super::exit::stops::stop_level(deal.buy_price, stop, deal.is_long()); + let level = super::super::exit::level_off_buy(deal.buy_price, stop, deal.is_long()); let stated = verify::stated_stop_level(&deal.sell_reason); let activation = verify::stop_jump_level(deal, exit) .and_then(|jump_at| verify::archived_stop_jump(deal, jump_at, exit_points)); @@ -733,7 +735,9 @@ fn real_data_reproduction() { deal.step_lag_ms = core_lags.get(&deal.core_uid).copied().unwrap_or(0.0); if let (Some(cache), Some((exchange, market))) = (klines.as_ref(), address.as_ref()) { let btc = btc_of_exchange.get(exchange).map(String::as_str); - let track = deltas::track_for(cache, exchange, market, btc, &deal, &ticks, &covered); + let history = deltas::track_for(cache, exchange, market, btc, &deal, &ticks, &covered); + deal.bars = history.bars; + let track = history.track; match &track { Some(track) => { tracks.push(track.clone()); @@ -1040,6 +1044,42 @@ fn real_data_reproduction() { // variant column counts the same money the "Fact" column does. let fit = fit_for_search(&archived); let own = simulate(&deal, &ticks, &entry, &exit, entry_line.as_deref()); + // `MOON_TICKS_VARIANT="SellPrice=1.6,PriceDownTimer=3"` replays the deal under those values + // laid over its own, the way a variant column does, and prints the line it walked. + if let Ok(spec) = std::env::var("MOON_TICKS_VARIANT") { + let mut laid = values.clone(); + for pair in spec.split(',') { + if let Some((k, v)) = pair.split_once('=') { + laid.insert(k.trim().to_string(), v.trim().to_string()); + } + } + let lsv = StrategyValues { + values: &laid, + defaults: &defaults, + }; + let v_entry = if entry_model_for(&deal.kind) { + EntryParams::MoonShot(mshot_params(&lsv, ModelSettings::default())) + } else { + EntryParams::Fact + }; + let v_exit = exit_params(&lsv, ModelSettings::default()); + let out = simulate(&deal, &ticks, &v_entry, &v_exit, entry_line.as_deref()); + eprintln!( + " variant fill {:?} · exit {:?} · {:?}", + out.fill, + out.exit, + round3(out.profit_pct) + ); + if let Some(fill) = out.fill { + let w = ExitModel::new(&v_exit).walk(&deal, &ticks, fill); + let pts: Vec = w + .points + .iter() + .map(|p| format!("+{}ms {:.6}", p.t_ms - fill.t_ms, p.price)) + .collect(); + eprintln!(" variant line: {}", pts.join(", ")); + } + } let fact = profit_pct(&deal, deal.buy_price, deal.sell_price); eprintln!( " fit {fit} · own {:?} {:?} at {:?} · fact {:?}", diff --git a/crates/moon-core/src/db/tuner/ticks/unmodelled.rs b/crates/moon-core/src/db/tuner/ticks/unmodelled.rs new file mode 100644 index 00000000..2a118916 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/unmodelled.rs @@ -0,0 +1,249 @@ +//! The exit fields a strategy can switch on that the model does not have — what a search and a +//! write must warn about: the search ran without them, and a strategy that keeps them on will not +//! exit the way the columns counted. +//! +//! The list is the exit side of the strategy window — Stops, Sell order, SellShot, SellSpread, +//! Delta Modifiers — less the fields the model reads ([`super::params::param_keys`]) and less +//! those that do not change WHERE or WHEN the position is sold: +//! +//! - the panic sell's execution — `AllowedDrop`, `AllowedDrop3`, `StopLossSpread`, +//! `StopSpreadAdd1mDelta`, `TrailingSpread`: the verdict judges a stop by its decision, and a +//! variant that keeps the trade's stop takes the fact's own sale (`record::StopAnchor`); +//! - the entry — `SellEMACheckEnter` (checks the EMA filter before the BUY), `BuyModifier`, +//! `DetectModifier`; +//! - the EMA sell — `SellByCustomEMA` and its `SellEMADelay`: unused, and left out of the list +//! (the developer's call, 2026-09-24); +//! - what acts only by hand or only in another kind — `SplitPiece` (a chart menu item), +//! `UseMarketStop`/`MarketStopLevel` (Manual), `SellPriceAbsolute`/`SellFromAssets`/ +//! `SellQuantity` (NewListing): none of them is a kind the tuner runs; +//! - the fields of a listed switch (`SecondStopLoss`, `BV_SV_Ratio`, `SellEMADelay`, …): the +//! switch stands for them. +//! +//! "Switched on" is the Strategies window's own reading: the field's dependency rule holds +//! (`assets/param_deps.toml`, [`FieldDeps`]) and its value differs from the core's default — the +//! live schema's, else the one written here, which is the site's (`moonbot.pro`, the Sell order +//! and Stops tabs) or, where the site is silent, the value the live strategies leave out (24.09). + +use std::collections::HashMap; + +use super::exit::UnmodelledRule; +use super::params::ParamSection; +use crate::feed::strategy_deps::{FieldDeps, Values, as_bool}; + +/// A further condition a watched field only acts under, beyond its dependency rule. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Also { + Nothing, + /// `UseScalpingMode` acts only while `SellPrice` is under 1 % (the site's Sell order tab). + SellPriceUnderOne, + /// SellShot keeps the sell at a distance only when there is one (`SellShotDistance` ≠ 0) — + /// as `params::unmodelled_rule` reads it. + SellShotDistance, +} + +/// One exit field the model does not have. +#[derive(Clone, Copy, Debug)] +struct Watched { + key: &'static str, + section: ParamSection, + /// The core's default when no live schema says it. + default: &'static str, + also: Also, + /// The rule of the model it stands for, when it takes the trade out of the verdict rather + /// than only warning. + rule: Option, +} + +const fn watched(key: &'static str, section: ParamSection, default: &'static str) -> Watched { + Watched { + key, + section, + default, + also: Also::Nothing, + rule: None, + } +} + +/// Every exit field a strategy can switch on that the model does not have, in the strategy +/// window's order. +const WATCHED: &[Watched] = &[ + // Stops. + Watched { + rule: Some(UnmodelledRule::StopLadder), + ..watched("UseSecondStop", ParamSection::Stops, "NO") + }, + Watched { + rule: Some(UnmodelledRule::StopLadder), + ..watched("UseStopLoss3", ParamSection::Stops, "NO") + }, + // A stop on the ratio of buy to sell volume, over trades the tape has but a rule it does not. + watched("UseBV_SV_Stop", ParamSection::Stops, "NO"), + // Stops taken from a Telegram signal. + watched("UseSignalStops", ParamSection::Stops, "NO"), + // Keeps the stop's sale and PriceDown off the liquidation price. + watched("DontSellBelowLiq", ParamSection::Stops, "NO"), + watched("StopAboveLiq", ParamSection::Stops, "0"), + // The stop of a grid position stays at the first order's. + watched("StopLossFixed", ParamSection::Stops, "NO"), + // A panic sell on a delisting message. + watched("PanicSellDelisted", ParamSection::Stops, "NO"), + // Sell order. + // NO places no sell at all. + Watched { + rule: Some(UnmodelledRule::NoAutoSell), + ..watched("AutoSell", ParamSection::SellOrder, "YES") + }, + // PriceDown counted to `PriceDownAllowedDrop`, not to the buy (the core developer's answer + // 10 and the site); the model counts to the buy, which is NO. + watched("PriceDownToAllowedDrop", ParamSection::SellOrder, "NO"), + // The sell placed under the ASK book's walls, up to 2 % — the book, which the tape has not. + Watched { + also: Also::SellPriceUnderOne, + ..watched("UseScalpingMode", ParamSection::SellOrder, "NO") + }, + // Sells when the filters' bounds are left, seconds after the buy; 0 is off. + watched("SellByFilters", ParamSection::SellOrder, "0"), + // SellShot. + Watched { + also: Also::SellShotDistance, + rule: Some(UnmodelledRule::SellShot), + ..watched("IgnoreSellShot", ParamSection::SellShot, "YES") + }, + // SellSpread. + Watched { + rule: Some(UnmodelledRule::SellSpread), + ..watched("IgnoreSellSpread", ParamSection::SellSpread, "YES") + }, +]; + +/// The fields the dependency rules of [`WATCHED`] and its extra conditions read, spelled as the +/// strategy dump spells them — a read by key is case-sensitive, and [`FieldDeps`] hands the +/// names back lowercase. A unit test holds this against the bundled rules. +const CONDITION_KEYS: &[&str] = &[ + "HODLmode", + "AutoSell", + "UseStopLoss", + "PriceDownTimer", + "PriceDownRelative", + "SellPrice", + "SellShotDistance", +]; + +/// One field of one strategy that switches on exit behaviour the model does not have. +#[derive(Clone, Debug, PartialEq)] +pub struct UnmodelledField { + /// The field, as the strategy window names it. + pub key: &'static str, + /// The strategy's value. + pub value: String, + /// The strategy window's section the field sits in. + pub section: ParamSection, + /// The model's rule it stands for, when the model does not judge a trade under it at all; + /// `None` for a field it only warns about. + pub rule: Option, +} + +/// Every field a read must fetch for [`unmodelled_fields`] to answer. +pub fn watched_keys() -> Vec { + WATCHED + .iter() + .map(|w| w.key) + .chain(CONDITION_KEYS.iter().copied()) + .map(str::to_string) + .collect() +} + +/// The exit fields `values` switches on that the model does not have, in the strategy window's +/// order. +/// +/// Args: +/// values: The strategy's values by field name, as `strategy_current_values` reads them — a +/// field left at its default is absent. +/// defaults: The live schema's numeric defaults, lowercase names (`strategy_field_defaults`); +/// empty without a connected core. +/// deps: The fields' dependency rules. +pub fn unmodelled_fields( + values: &HashMap, + defaults: &HashMap, + deps: &FieldDeps, +) -> Vec { + // The Strategies window's view of the strategy: every stored field, lowercase, the schema's + // default filling a condition field the dump leaves out — absent means "not this kind's". + let mut effective: Values = values + .iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v.clone())) + .collect(); + for key in CONDITION_KEYS { + let lower = key.to_ascii_lowercase(); + if let Some(default) = defaults.get(&lower) { + effective + .entry(lower) + .or_insert_with(|| default.to_string()); + } + } + WATCHED + .iter() + .filter_map(|w| { + let value = values.get(w.key)?; + let default = defaults + .get(&w.key.to_ascii_lowercase()) + .map(f64::to_string) + .unwrap_or_else(|| w.default.to_string()); + let on = !same_value(value, &default) + && deps.field_active(w.key, &effective) + && also_holds(w.also, values, defaults); + on.then(|| UnmodelledField { + key: w.key, + value: value.clone(), + section: w.section, + rule: w.rule, + }) + }) + .collect() +} + +/// Whether the extra condition of a watched field holds. +fn also_holds( + also: Also, + values: &HashMap, + defaults: &HashMap, +) -> bool { + let num = |key: &str, fallback: f64| { + values + .get(key) + .and_then(|v| number(v)) + .or_else(|| defaults.get(&key.to_ascii_lowercase()).copied()) + .unwrap_or(fallback) + }; + match also { + Also::Nothing => true, + // The model's own fallback for `SellPrice` (`ExitParams::default`). + Also::SellPriceUnderOne => num("SellPrice", 1.0) < 1.0, + Also::SellShotDistance => num("SellShotDistance", 0.0) != 0.0, + } +} + +/// Whether two spellings of a field's value are the same value: as booleans when both are one +/// (`YES`, `1`, `True`…), as numbers when both are (`0.0` and `0`), else as text. +fn same_value(a: &str, b: &str) -> bool { + if let (Some(x), Some(y)) = (as_bool(a), as_bool(b)) { + return x == y; + } + if let (Some(x), Some(y)) = (number(a), number(b)) { + return (x - y).abs() < 1e-9; + } + a.trim().eq_ignore_ascii_case(b.trim()) +} + +/// A strategy number: `1.5`, `1,5`, `1.5%`. +fn number(s: &str) -> Option { + s.trim() + .trim_end_matches('%') + .replace(',', ".") + .parse::() + .ok() + .filter(|v| v.is_finite()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs b/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs new file mode 100644 index 00000000..59945323 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs @@ -0,0 +1,205 @@ +use super::*; +use crate::db::tuner::ticks::params::{StrategyValues, unmodelled_rule}; + +fn values(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +fn keys(found: &[UnmodelledField]) -> Vec<&'static str> { + found.iter().map(|f| f.key).collect() +} + +/// A strategy the model covers raises nothing; the fields the dump leaves out are at default. +#[test] +fn a_plain_strategy_raises_nothing() { + let v = values(&[ + ("SellPrice", "1.5"), + ("IgnoreSellShot", "YES"), + ("SellShotDistance", "0.1"), + ("IgnoreSellSpread", "True"), + ("AutoSell", "True"), + ("UseScalpingMode", "YES"), + ]); + assert!(unmodelled_fields(&v, &HashMap::new(), &FieldDeps::bundled()).is_empty()); +} + +#[test] +fn switched_on_fields_come_with_value_and_section() { + let v = values(&[ + ("UseSecondStop", "YES"), + ("DontSellBelowLiq", "True"), + ("StopAboveLiq", "50"), + ("PanicSellDelisted", "YES"), + ("IgnoreSellSpread", "NO"), + ]); + let found = unmodelled_fields(&v, &HashMap::new(), &FieldDeps::bundled()); + assert_eq!( + keys(&found), + [ + "UseSecondStop", + "DontSellBelowLiq", + "StopAboveLiq", + "PanicSellDelisted", + "IgnoreSellSpread" + ] + ); + assert_eq!(found[2].value, "50"); + assert_eq!(found[2].section, ParamSection::Stops); + assert_eq!(found[3].section, ParamSection::Stops); + assert_eq!(found[0].rule, Some(UnmodelledRule::StopLadder)); + assert_eq!(found[4].rule, Some(UnmodelledRule::SellSpread)); + assert_eq!(found[1].rule, None, "a warning, not a rule"); +} + +/// A field whose dependency rule does not hold is not in effect, as the Strategies window greys +/// it out: the stop's options mean nothing with `UseStopLoss` off. +#[test] +fn a_field_under_a_switch_that_is_off_is_not_in_effect() { + let v = values(&[("UseStopLoss", "NO"), ("DontSellBelowLiq", "YES")]); + assert!(unmodelled_fields(&v, &HashMap::new(), &FieldDeps::bundled()).is_empty()); + let on = values(&[("UseStopLoss", "YES"), ("DontSellBelowLiq", "YES")]); + assert_eq!( + keys(&unmodelled_fields( + &on, + &HashMap::new(), + &FieldDeps::bundled() + )), + ["DontSellBelowLiq"] + ); +} + +/// The live schema's default wins over the one written here: a value AT it raises nothing. +#[test] +fn the_schema_default_decides_what_is_changed() { + let v = values(&[("StopAboveLiq", "50")]); + let defaults: HashMap = [("stopaboveliq".to_string(), 50.0)].into(); + assert!(unmodelled_fields(&v, &defaults, &FieldDeps::bundled()).is_empty()); + // A schema that says the stop is off by default fills `UseStopLoss` for the rule. + let v = values(&[("DontSellBelowLiq", "YES")]); + let off: HashMap = [("usestoploss".to_string(), 0.0)].into(); + assert!(unmodelled_fields(&v, &off, &FieldDeps::bundled()).is_empty()); +} + +/// `UseScalpingMode` acts only under a 1 % `SellPrice`; SellShot only with a distance. +#[test] +fn the_extra_conditions_hold_the_field_back() { + let deps = FieldDeps::bundled(); + let wide = values(&[("UseScalpingMode", "YES"), ("SellPrice", "1.2")]); + assert!(unmodelled_fields(&wide, &HashMap::new(), &deps).is_empty()); + let tight = values(&[("UseScalpingMode", "YES"), ("SellPrice", "0,5")]); + assert_eq!( + keys(&unmodelled_fields(&tight, &HashMap::new(), &deps)), + ["UseScalpingMode"] + ); + let still = values(&[("IgnoreSellShot", "NO"), ("SellShotDistance", "0")]); + assert!(unmodelled_fields(&still, &HashMap::new(), &deps).is_empty()); + let shot = values(&[("IgnoreSellShot", "NO"), ("SellShotDistance", "0.5")]); + let found = unmodelled_fields(&shot, &HashMap::new(), &deps); + assert_eq!(keys(&found), ["IgnoreSellShot"]); + assert_eq!(found[0].rule, Some(UnmodelledRule::SellShot)); +} + +/// `AutoSell` is watched the other way round: NO is what the model does not have. +#[test] +fn auto_sell_off_is_raised() { + let v = values(&[("AutoSell", "NO")]); + let found = unmodelled_fields(&v, &HashMap::new(), &FieldDeps::bundled()); + assert_eq!(keys(&found), ["AutoSell"]); + assert_eq!(found[0].rule, Some(UnmodelledRule::NoAutoSell)); +} + +/// The warning and the verdict agree: every strategy the model does not judge +/// (`params::unmodelled_rule`) raises the field of that rule. +#[test] +fn every_unmodelled_rule_is_raised() { + let cases = [ + values(&[("AutoSell", "NO")]), + values(&[("UseSecondStop", "YES")]), + values(&[("UseStopLoss3", "YES"), ("UseStopLoss", "YES")]), + values(&[("IgnoreSellShot", "NO"), ("SellShotDistance", "0.1")]), + values(&[("IgnoreSellSpread", "NO")]), + ]; + let defaults = HashMap::new(); + for v in cases { + let rule = unmodelled_rule(&StrategyValues { + values: &v, + defaults: &defaults, + }) + .expect("a rule the model does not have"); + let found = unmodelled_fields(&v, &defaults, &FieldDeps::bundled()); + assert!( + found.iter().any(|f| f.rule == Some(rule)), + "{v:?} -> {found:?}" + ); + } +} + +/// Every field a watched rule's condition reads is fetched, spelled as the dump spells it — a +/// condition left unread would answer on its absence, which does not block. +#[test] +fn the_condition_fields_are_fetched() { + let deps = FieldDeps::bundled(); + let fetched: Vec = watched_keys() + .into_iter() + .map(|k| k.to_ascii_lowercase()) + .collect(); + for w in WATCHED { + for field in deps.conditions_of(w.key) { + assert!( + fetched.iter().any(|k| k == field), + "{} depends on {field}, which is never read", + w.key + ); + } + } +} + +/// A watched field is outside the model: none of them is a field the models act on. +#[test] +fn no_watched_field_is_read_by_the_model() { + for w in WATCHED { + assert!( + !crate::db::tuner::ticks::params::is_model_only(w.key) + && !crate::db::tuner::ticks::TICK_PARAMS + .iter() + .any(|p| p.key == w.key), + "{} is read by the model", + w.key + ); + } +} + +/// Each watched field sits in the section `assets/param_deps.toml` files it under — the section +/// the dialog names. +#[test] +fn every_watched_field_sits_in_its_section() { + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/param_deps.toml"); + let text = std::fs::read_to_string(&path).expect("param_deps.toml"); + let mut section = ""; + let mut filed: HashMap = HashMap::new(); + for line in text.lines() { + let line = line.trim(); + if let Some(title) = line + .strip_prefix("# === ") + .and_then(|s| s.strip_suffix(" ===")) + { + section = title; + } else if let Some(rest) = line.strip_prefix('"') { + if let Some(end) = rest.find('"') { + filed.insert(rest[..end].to_string(), section.to_string()); + } + } + } + for w in WATCHED { + assert_eq!( + filed.get(w.key).map(String::as_str), + Some(w.section.schema_title()), + "{}", + w.key + ); + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 48c1a0b2..4b7f344b 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -44,9 +44,10 @@ //! fixed, when the stored reason keeps it, and the moment it activated ([`verify_stop`]). A stop the core fired and the model never did is a miss. use super::exit::ExitModel; +use super::exit::level_off_buy; use super::exit::line::LinePoint; +use super::exit::stops::stop_pct; use super::exit::stops::trailing::trailing_level; -use super::exit::stops::{stop_level, stop_pct}; use super::mshot::MshotParams; use super::settings::ModelSettings; use super::{ @@ -501,7 +502,7 @@ fn verify_stop( closed: Exit, exit_points: Option<&[(i64, f64)]>, ) -> (Option, Option, Option<(usize, usize)>) { - let level = stop_level( + let level = level_off_buy( deal.buy_price, stop_pct(exit, deal, deal.buy_ms), deal.is_long(), @@ -562,7 +563,8 @@ pub(super) fn stop_jump_level(deal: &Deal, exit: &ExitParams) -> Option { } let pct = stop_pct(exit, deal, deal.buy_ms); (reason_starts_with(reason, REASON_STOP) && pct != 0.0).then(|| { - stated_stop_level(reason).unwrap_or_else(|| stop_level(deal.buy_price, pct, deal.is_long())) + stated_stop_level(reason) + .unwrap_or_else(|| level_off_buy(deal.buy_price, pct, deal.is_long())) }) } @@ -679,7 +681,7 @@ fn matched_points_of( pub const REASON_TAKE: &str = "Sell Price"; /// The core's `sellreason` prefixes for a position its moving line closed. -pub const REASONS_LINE: [&str; 3] = ["Auto Price Down", "Sell Level", "SellShot"]; +pub const REASONS_LINE: [&str; 2] = ["Auto Price Down", "Sell Level"]; /// The core's `sellreason` prefix for a position its stop closed. pub const REASON_STOP: &str = "StopLoss"; @@ -701,7 +703,8 @@ pub(super) fn is_stop_reason(sell_reason: &str) -> bool { /// Whether the model's exit rule is the one the core's `sellreason` names, so the two prices /// are comparable: the take against "Sell Price", the moving line against the PriceDown / -/// SellLevel / SellShot reasons, the stop against "StopLoss …". +/// SellLevel reasons, the stop against "StopLoss …". A SellShot close has no counterpart: the +/// model has no SellShot, and a strategy under it is not judged (`exit::UnmodelledRule`). fn exit_rule_matches(kind: ExitKind, sell_reason: &str) -> bool { let reason = sell_reason.trim(); let starts = |prefix: &str| reason_starts_with(reason, prefix); diff --git a/crates/moon-core/src/feed/mod.rs b/crates/moon-core/src/feed/mod.rs index a972f485..c438ab91 100644 --- a/crates/moon-core/src/feed/mod.rs +++ b/crates/moon-core/src/feed/mod.rs @@ -12,6 +12,7 @@ pub mod news_marks; mod order_edit; pub mod report_traces; mod strategies; +pub mod strategy_deps; pub mod strategy_order; pub mod synth; mod trade; diff --git a/crates/moon-core/src/feed/strategy_deps.rs b/crates/moon-core/src/feed/strategy_deps.rs new file mode 100644 index 00000000..c2444aba --- /dev/null +++ b/crates/moon-core/src/feed/strategy_deps.rs @@ -0,0 +1,200 @@ +//! Strategy-field dependency rules — whether a field is in effect given the values of OTHER +//! fields. The rules come from `assets/param_deps.toml` (`"Field" = "A=VAL;B<>VAL"`): a field is in +//! effect only when every condition of its rule holds, as the Strategies window greys it out +//! otherwise. +//! +//! The parsing and the evaluation live here so that a model can ask "is this field switched on" +//! without a UI (`db::tuner::ticks::unmodelled`, whose caller reads the file on every load of the +//! tuner's axis — so an edit reaches the tuner on its next load, and an open Strategies window only +//! under its development hot reload). The window keeps its own loading and that hot reload on top +//! of [`FieldDeps`]. Moved verbatim from the window's `strategies/rules.rs`, itself a port of +//! egui's. + +use std::collections::HashMap; + +/// External path relative to the cwd for development hot reload (`cargo run` uses the workspace +/// root). +pub const EXTERNAL: &str = "assets/param_deps.toml"; +/// Fallback bundled into the binary for release runs without adjacent assets. +const BUNDLED: &str = include_str!("../../../../assets/param_deps.toml"); + +/// Effective dependency values keyed by lowercase field name. +/// +/// Stored fields are overlaid with staged edits, while schema defaults fill omitted fields. +pub type Values = HashMap; + +/// Dependency-condition operator. +#[derive(Clone, Copy, Debug)] +enum Op { + Eq, + Ne, + Gt, + Lt, + Ge, + Le, +} + +/// One dependency condition: `field` (op) `value`. +#[derive(Clone, Debug)] +struct Cond { + field: String, + op: Op, + value: String, +} + +/// The parsed rules: each lowercase field name mapped to its conditions, joined by `;` as a +/// logical AND. +#[derive(Clone, Debug, Default)] +pub struct FieldDeps { + deps: HashMap>, +} + +impl FieldDeps { + /// The rules from the external file when present, otherwise from the bundled fallback. + pub fn load() -> Self { + match std::fs::read_to_string(EXTERNAL) { + Ok(content) => Self::parse(&content), + Err(_) => Self::bundled(), + } + } + + /// The rules bundled into the binary. + pub fn bundled() -> Self { + Self::parse(BUNDLED) + } + + /// Parse manually edited `"Field" = "condition"` entries line by line. + /// + /// This accepts duplicate keys (the last wins), full-line `#` comments, the `[deps]` header, + /// and quotes. + /// Unlike strict TOML, one malformed key does not invalidate the entire file. + pub fn parse(content: &str) -> Self { + let mut deps = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('[') { + continue; + } + // Split on the FIRST `=`; any `=` inside the value follows the closing key quote. + let Some(eq) = line.find('=') else { continue }; + let key = line[..eq].trim().trim_matches('"').trim().to_lowercase(); + let expr = line[eq + 1..].trim().trim_matches('"').trim(); + if key.is_empty() { + continue; + } + deps.insert(key, parse_conds(expr)); + } + Self { deps } + } + + /// How many fields carry a rule. + pub fn len(&self) -> usize { + self.deps.len() + } + + /// Whether no field carries a rule. + pub fn is_empty(&self) -> bool { + self.deps.is_empty() + } + + /// Return whether a field is active and editable under the current values. + /// + /// Every condition must hold; a field without a rule is active. A condition referring to a + /// field absent from `values` is inapplicable because that field does not exist for this + /// strategy kind, so it does not block. `selected_values` inserts every schema field with its + /// default or an empty value, making absence mean "not part of this kind" while an unsaved + /// field is still compared using its default. + pub fn field_active(&self, name: &str, values: &Values) -> bool { + match self.deps.get(&name.to_lowercase()) { + None => true, + Some(conds) => conds.iter().all(|c| match values.get(&c.field) { + None => true, + Some(v) => cond_true(c, v), + }), + } + } + + /// The fields `name`'s rule reads, lowercase — what a caller must have the values of for + /// [`Self::field_active`] to answer on them rather than on their absence. + pub fn conditions_of(&self, name: &str) -> impl Iterator { + self.deps + .get(&name.to_lowercase()) + .into_iter() + .flatten() + .map(|c| c.field.as_str()) + } +} + +/// Evaluate condition `c` against value `v`. +/// +/// `=` and `<>` compare booleans or strings; `>`, `<`, `>=`, and `<=` compare numbers. A +/// nonnumeric operand makes a numeric condition false. +fn cond_true(c: &Cond, v: &str) -> bool { + match c.op { + Op::Eq => value_eq(v, &c.value), + Op::Ne => !value_eq(v, &c.value), + _ => match (v.trim().parse::(), c.value.trim().parse::()) { + (Ok(a), Ok(e)) => match c.op { + Op::Gt => a > e, + Op::Lt => a < e, + Op::Ge => a >= e, + Op::Le => a <= e, + _ => true, + }, + _ => false, + }, + } +} + +/// Interpret the core's boolean forms: `1/0`, `Yes/No`, and `true/false`. +/// +/// None means the value is a number or string rather than a boolean. +pub fn as_bool(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "yes" | "true" | "1" | "on" => Some(true), + "no" | "false" | "0" | "off" | "" => Some(false), + _ => None, + } +} + +/// Compare condition values as booleans when BOTH sides are boolean forms, including `0/1`. +/// +/// This makes `IgnoreVolume=NO` match the raw value `"0"`; all other values compare as strings. +pub fn value_eq(actual: &str, expected: &str) -> bool { + match (as_bool(actual), as_bool(expected)) { + (Some(a), Some(e)) => a == e, + _ => actual.eq_ignore_ascii_case(expected), + } +} + +/// Parse `A=VAL;B<>VAL;C>1` into lowercase field/value conditions. +/// +/// Operators are checked longest first: `<>`, `>=`, and `<=` precede `>`, `<`, and `=`. +fn parse_conds(expr: &str) -> Vec { + const OPS: [(&str, Op); 6] = [ + ("<>", Op::Ne), + (">=", Op::Ge), + ("<=", Op::Le), + (">", Op::Gt), + ("<", Op::Lt), + ("=", Op::Eq), + ]; + expr.split(';') + .filter_map(|part| { + let part = part.trim(); + if part.is_empty() { + return None; + } + OPS.iter().find_map(|&(s, op)| { + part.find(s).map(|i| Cond { + field: part[..i].trim().to_lowercase(), + op, + value: part[i + s.len()..].trim().to_lowercase(), + }) + }) + }) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/feed/strategy_deps/tests.rs b/crates/moon-core/src/feed/strategy_deps/tests.rs new file mode 100644 index 00000000..11d94a63 --- /dev/null +++ b/crates/moon-core/src/feed/strategy_deps/tests.rs @@ -0,0 +1,76 @@ +use super::*; + +fn values(pairs: &[(&str, &str)]) -> Values { + pairs + .iter() + .map(|(k, v)| (k.to_lowercase(), (*v).to_string())) + .collect() +} + +#[test] +fn every_condition_must_hold() { + let deps = + FieldDeps::parse("[deps]\n\"SellShotDistance\" = \"AutoSell=YES;IgnoreSellShot=NO\"\n"); + let on = values(&[("AutoSell", "YES"), ("IgnoreSellShot", "0")]); + assert!(deps.field_active("SellShotDistance", &on)); + let off = values(&[("AutoSell", "YES"), ("IgnoreSellShot", "YES")]); + assert!(!deps.field_active("sellshotdistance", &off)); +} + +/// A condition on a field the kind does not have does not block, and a field with no rule is +/// always in effect. +#[test] +fn an_absent_condition_field_and_a_ruleless_field_are_active() { + let deps = FieldDeps::parse("\"PriceDownDelay\" = \"AutoSell=YES;PriceDownTimer<>0\"\n"); + assert!(deps.field_active("PriceDownDelay", &values(&[("PriceDownTimer", "1")]))); + assert!(!deps.field_active("PriceDownDelay", &values(&[("PriceDownTimer", "0")]))); + assert!(deps.field_active("Anything", &Values::new())); +} + +#[test] +fn numeric_conditions_compare_numbers() { + let deps = FieldDeps::parse("\"BuyStepKind\" = \"OrdersCount>1\"\n"); + assert!(deps.field_active("BuyStepKind", &values(&[("OrdersCount", "2")]))); + assert!(!deps.field_active("BuyStepKind", &values(&[("OrdersCount", "1")]))); + assert!(!deps.field_active("BuyStepKind", &values(&[("OrdersCount", "many")]))); +} + +#[test] +fn conditions_of_names_the_fields_a_rule_reads() { + let deps = FieldDeps::parse("\"StopLoss3\" = \"UseStopLoss=YES;UseStopLoss3=YES\"\n"); + let fields: Vec<&str> = deps.conditions_of("StopLoss3").collect(); + assert_eq!(fields, ["usestoploss", "usestoploss3"]); + assert_eq!(deps.conditions_of("Nothing").count(), 0); +} + +/// The bundled file parses and carries the rules the tuner's warning leans on. +#[test] +fn the_bundled_rules_hold_the_sell_switches() { + let deps = FieldDeps::bundled(); + let fields: Vec<&str> = deps.conditions_of("SellShotDistance").collect(); + assert!(fields.contains(&"ignoresellshot"), "{fields:?}"); + assert!( + deps.conditions_of("UseScalpingMode") + .any(|f| f == "autosell") + ); +} + +#[test] +fn booleans_compare_across_spellings() { + assert!(value_eq("0", "no")); + assert!(value_eq("True", "yes")); + assert!(!value_eq("1", "no")); + assert!(value_eq("Trade", "trade")); + assert_eq!(as_bool("50"), None); +} + +/// MoonShot's ask adjustment moves the take only through the ask branch: with +/// `MShotSellAtLastPrice` off the take is `SellPrice` alone, and the adjustment is not in effect. +#[test] +fn the_ask_adjustment_hangs_on_sell_at_last_price() { + let deps = FieldDeps::bundled(); + let on = values(&[("MShotSellAtLastPrice", "YES")]); + assert!(deps.field_active("MShotSellPriceAdjust", &on)); + let off = values(&[("MShotSellAtLastPrice", "0")]); + assert!(!deps.field_active("MShotSellPriceAdjust", &off)); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs index 4219e0d5..40a6d9b3 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs @@ -148,7 +148,11 @@ impl AnalyticsView { .variant(MoonButtonVariant::Soft) .label(t!("analytics.tuner.suggest_one").to_string()) .disabled(running || self.ticks.sel_field.is_none()) - .on_click(cx.listener(|this, _, _, cx| this.ticks_suggest_one(cx))) + .on_click( + cx.listener(|this, _, window, cx| { + this.ticks_suggest_one(window, cx) + }), + ) .render(), ), ) @@ -158,7 +162,7 @@ impl AnalyticsView { .variant(MoonButtonVariant::Blue) .label(t!("analytics.tuner.suggest_run").to_string()) .disabled(running) - .on_click(cx.listener(|this, _, _, cx| this.ticks_suggest(cx))) + .on_click(cx.listener(|this, _, window, cx| this.ticks_suggest(window, cx))) .render(), ), ); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs index 3b57ec38..59493738 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs @@ -23,6 +23,7 @@ fn row(tape: TapeStatus) -> DealRow { profit: None, deltas: Deltas::default(), delta_track: None, + bars: None, tick: None, pre_spike_ask: None, archived_take: None, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index 62fe854f..36524671 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -7,7 +7,9 @@ //! Sell order, SellShot, SellSpread, Delta Modifiers — each with every field it holds for the //! scope's kinds, and a tick in its heading that admits all its knobs at once. Only the knobs //! are live; a field the model reads but does not turn, or does not know at all, is drawn -//! greyed with the strategies' value, so the grid shows the whole section as Moonbot does. +//! greyed with the strategies' value, so the grid shows the whole section as Moonbot does. The +//! sections the model does not have at all (SellShot, SellSpread) are drawn muted, every row +//! inactive, and their heading says so. //! //! The search still gates by group, Entry and Exit: a group the model does not reproduce well //! enough (the share gate of the search settings) is not searched, and the first section holding @@ -351,6 +353,13 @@ impl AnalyticsView { .collect(); let (all_on, some_on) = self.ticks_tick_state(&keys); let mut notes: Vec<(String, u32)> = Vec::new(); + let modelled = section.section.modelled(); + if !modelled { + notes.push(( + t!("analytics.ticks.section_unmodelled").to_string(), + p.text_muted, + )); + } match data { // Only a LOADED empty scope says so; a load in flight or a failed one has its own // note in the table. @@ -428,7 +437,7 @@ impl AnalyticsView { .flex_none() .cursor_pointer() .text_size(design::t_body(cx)) - .text_color(moon(p.text)) + .text_color(moon(if modelled { p.text } else { p.text_muted })) .child(title) .on_click( cx.listener(move |this, _, _, cx| this.ticks_toggle_section(which, cx)), @@ -464,6 +473,10 @@ impl AnalyticsView { let (tip, name_color) = match role { RowRole::Fixed => (t!("analytics.ticks.row_fixed").to_string(), p.text_soft), RowRole::Outside => (t!("analytics.ticks.row_outside").to_string(), p.text_muted), + RowRole::Unmodelled => ( + t!("analytics.ticks.row_unmodelled").to_string(), + p.text_muted, + ), // A knob of the entry group while a kind of the scope has no entry model. RowRole::Knob(_) => ( t!( diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index cb21c422..4fb0e7f2 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -22,16 +22,19 @@ use gpui::*; use super::super::super::AnalyticsView; use super::state::{DealRow, NowValue, OwnValues, RowAddress, TapeStatus, TicksData}; use super::tape; +use super::unmodelled::{UnmodelledMap, unmodelled_map}; use crate::analytics::bg::ReadLane; use crate::analytics::refresh::{CatchUpOutcome, report_result_is_stale}; use moon_core::db::ReadFail; use moon_core::db::order_traces::{TraceEntry, read_many}; +use moon_core::db::tuner::ticks::unmodelled::watched_keys; use moon_core::db::tuner::ticks::{ Deal, DealsRead, EntryParams, ModelSettings, OwnLines, deltas, entry_model_for, infer_tick, model_window, params, prepare_deal, required_spans, verify, }; use moon_core::db::tuner::{strategy_current_values, strategy_values_at}; use moon_core::feed::report_traces::ArchivedLineKind; +use moon_core::feed::strategy_deps::FieldDeps; use moon_core::feed::types::Tick; use moon_core::market::kline_cache::KlineCache; use moon_core::market::trade_replay::venue_caps::trade_route; @@ -45,17 +48,20 @@ use moon_core::market::trade_replay::{ /// stalls — past it the rows still unanswered fold as missing, and the log says how many. const HELD_ANSWER_WAIT: Duration = Duration::from_secs(240); -/// What stage A brings back: the deals and the grid's "now" values. +/// What stage A brings back: the deals, the grid's "now" values, the strategies' own values and +/// the exit fields outside the model they switch on. type StageA = ( Result, HashMap, OwnValues, + Arc, ); /// What stage B publishes beside the rows: the strategies' values and the grid's layout. struct ScopeView { now: HashMap, own: OwnValues, + unmodelled: Arc, grid: Arc<[super::sections::GridSection]>, } @@ -111,7 +117,14 @@ impl AnalyticsView { // keys only ride along in the maps. The schema signature comes off the same store read: // a schema that moves on after it makes the grid ask for another load (`grid.rs`), whose // keys then cover it. + // The exit fields the model does not have ride along too, for the warning they raise. let mut keys = params::param_keys(); + for key in watched_keys() { + if !keys.contains(&key) { + keys.push(key); + } + } + let defaults = self.filter_defaults(cx); self.ticks.keys_sig = Some({ let backend = self.backend.read(cx); let store = backend.session.store(); @@ -157,10 +170,19 @@ impl AnalyticsView { .as_ref() .map(|read| own_values(&read.deals, &keys)) .unwrap_or_default(); - let now = now_values(&targets, &keys, &own); - (deals, now, own) + let (now, selected) = now_values(&targets, &keys, &own); + // The rules are read per load: an edit of the file reaches the warning on the + // axis' next load, never a process-lifetime copy. + let unmodelled = unmodelled_map( + own.iter() + .map(|(&(sid, core), values)| ((sid, Some(core)), values.as_ref())) + .chain(selected.iter().map(|(key, values)| (*key, values.as_ref()))), + &defaults, + &FieldDeps::load(), + ); + (deals, now, own, Arc::new(unmodelled)) }, - move |this, (deals, now, own): StageA, cx| { + move |this, (deals, now, own, unmodelled): StageA, cx| { if this.ticks.seq != req { return; } @@ -192,6 +214,7 @@ impl AnalyticsView { ScopeView { now, own, + unmodelled, grid, }, addresses, @@ -327,6 +350,7 @@ impl AnalyticsView { kinds, now: scope.now, own: scope.own, + unmodelled: scope.unmodelled, grid: scope.grid, }; data.retain_within_cap(); @@ -598,14 +622,15 @@ fn ask_held( } /// The grid's "now" column: every selected strategy's current value per field, folded to -/// one value or "varies". A target on a known core that the deals' strategies already read -/// (`own`, from [`own_values`]) is not read again. +/// one value or "varies" — and those values per target. A target on a known core that the deals' +/// strategies already read (`own`, from [`own_values`]) is not read again. fn now_values( targets: &[(i64, Option)], keys: &[String], own: &OwnValues, -) -> HashMap { +) -> (HashMap, Vec) { let mut seen: HashMap>> = HashMap::new(); + let mut read = Vec::with_capacity(targets.len()); for &(sid, core) in targets { let values = match core.and_then(|core| own.get(&(sid, core))) { Some(values) => Arc::clone(values), @@ -616,8 +641,10 @@ fn now_values( .or_default() .push(values.get(key).cloned()); } + read.push(((sid, core), values)); } - seen.into_iter() + let now = seen + .into_iter() .map(|(key, values)| { let first = values.first().cloned().flatten(); let same = values.iter().all(|v| v.as_deref() == first.as_deref()); @@ -628,9 +655,13 @@ fn now_values( }; (key, value) }) - .collect() + .collect(); + (now, read) } +/// A selected strategy's current values, by `(strategy_id, core)`. +type SelectedValues = ((i64, Option), Arc>); + /// Every strategy of `deals` as it stands now, read once per `(strategy_id, core)` — the base /// each deal's variants run over ([`TicksData::own`]). A strategy that cannot be read gets an /// empty map, and its deals read every field at its default, as the grid's "now" does. @@ -770,6 +801,7 @@ pub(super) fn replay_row_with( row.entry_line = lines.entry_points.clone(); row.held = None; row.deal.delta_track = None; + row.deal.bars = None; let Some(address) = row.address.clone() else { return; }; @@ -813,9 +845,9 @@ pub(super) fn replay_row_with( }; let exit = params::exit_params(&sv, model); // The deltas along the window, before the record's inputs: the stop anchor reads the stop - // through them. - row.deal.delta_track = klines.and_then(|cache| { - deltas::track_for( + // through them. The bars they were read off go with the deal for SellLevel's look-back. + if let Some(cache) = klines { + let history = deltas::track_for( cache, &address.exchange_key, &address.market, @@ -823,8 +855,10 @@ pub(super) fn replay_row_with( &row.deal, &ticks, &covered, - ) - }); + ); + row.deal.delta_track = history.track; + row.deal.bars = history.bars; + } // What the core's own record fixes: the ask its take was lifted to, the take as placed, // where the entry order was placed, what the fact proves about the stop, the entry the // trade ran with. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index d73356b5..9ee02e71 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -48,6 +48,7 @@ mod sections; pub(in crate::analytics) mod state; mod tape; mod trade_pane; +mod unmodelled; mod variants; impl AnalyticsView { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index de8d6103..b3f8570b 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -30,6 +30,7 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { step_lag_ms: 0.0, stop_anchor: None, delta_track: None, + bars: None, own_entry: None, buy_set_ms: None, corridor: None, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs index 5eab99e6..fd2770e5 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs @@ -3,6 +3,8 @@ //! section holds for the scope's kinds, as the Strategies window shows them. Only the knobs of //! [`TICK_PARAMS`](moon_core::db::tuner::ticks::TICK_PARAMS) are searched; every other field is //! drawn fixed, so what the model does not turn yet stays in sight where the user looks for it. +//! The sections the model does not have at all — SellShot and SellSpread +//! ([`ParamSection::modelled`]) — keep their fields in sight too, every one of them inactive. //! //! The field lists come from the live schema of each deal's strategy kind — the store's strategy //! row gives the kind ordinal, as `strategies::logic::selected_sections` does; the `SignalType` @@ -30,6 +32,9 @@ pub(in crate::analytics::tuner) enum RowRole { Fixed, /// A field the model does not take into account. Outside, + /// A field of a section the model does not have at all ([`ParamSection::modelled`]): a + /// strategy that switches the section on is not judged. + Unmodelled, } /// One row: the field as the schema spells it, and its role. @@ -101,7 +106,7 @@ pub(in crate::analytics::tuner) fn layout( { for field in §ion.fields { if seen.insert(field.name.to_ascii_lowercase()) { - grid.rows.push(row(&field.name, knobs)); + grid.rows.push(row(&field.name, grid.section, knobs)); } } } @@ -120,9 +125,11 @@ pub(in crate::analytics::tuner) fn layout( out } -/// A schema field's row: a knob of the scope, a field the model reads, or one it does not. -fn row(name: &str, knobs: &[&'static TickParam]) -> GridRow { +/// A schema field's row: a knob of the scope, a field the model reads, or one it does not — +/// and every field of a section the model does not have, whatever the field. +fn row(name: &str, section: ParamSection, knobs: &[&'static TickParam]) -> GridRow { let role = match knobs.iter().find(|k| k.key.eq_ignore_ascii_case(name)) { + _ if !section.modelled() => RowRole::Unmodelled, Some(&knob) => RowRole::Knob(knob), None if is_model_only(name) => RowRole::Fixed, None => RowRole::Outside, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs index d3b4185e..eb655313 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs @@ -46,6 +46,10 @@ fn the_schema_places_every_field_and_marks_what_the_model_turns() { "Sell order\\SellShot", &["IgnoreSellShot", "SellShotPriceDown"], ), + section( + "Sell order\\SellSpread", + &["IgnoreSellSpread", "SellSpreadDistance"], + ), section( "Stops", &[ @@ -68,17 +72,23 @@ fn the_schema_places_every_field_and_marks_what_the_model_turns() { assert!(matches!(settings.rows[0].role, RowRole::Knob(p) if p.key == "MShotPrice")); assert_eq!(settings.rows[1].role, RowRole::Outside); - let shot = find(&out, ParamSection::SellShot); - assert_eq!(keys(shot)[..2], ["IgnoreSellShot", "SellShotPriceDown"]); - // Read by the SellShot walk, never turned. - assert_eq!(shot.rows[1].role, RowRole::Fixed); - // The knobs this schema left out follow under their own section. - assert!( - shot.rows[2..] - .iter() - .all(|r| matches!(r.role, RowRole::Knob(p) if p.section == ParamSection::SellShot)) - ); - assert!(shot.rows.iter().any(|r| r.key == "SellShotDistance")); + // SellShot and SellSpread are not modelled: every field is drawn, none of them live, and no + // knob follows under them. + for (s, names) in [ + ( + ParamSection::SellShot, + ["IgnoreSellShot", "SellShotPriceDown"], + ), + ( + ParamSection::SellSpread, + ["IgnoreSellSpread", "SellSpreadDistance"], + ), + ] { + let grid = find(&out, s); + assert_eq!(keys(grid), names); + assert!(grid.rows.iter().all(|r| r.role == RowRole::Unmodelled)); + assert_eq!(grid.knobs().count(), 0); + } let stops = find(&out, ParamSection::Stops); assert_eq!( diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index c98ad49b..4c5e1be3 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -109,6 +109,7 @@ impl DealRow { self.deal.stop_anchor = answer.deal.stop_anchor; self.deal.own_entry = answer.deal.own_entry; self.deal.delta_track = answer.deal.delta_track; + self.deal.bars = answer.deal.bars; self.ticks = answer.ticks; self.entry_line = answer.entry_line; self.held = answer.held; @@ -170,6 +171,9 @@ pub(in crate::analytics::tuner) struct TicksData { /// ([`PreparedDeal::own`]). The grid folds these into one "now" cell; a replay must not, /// or a field the strategies disagree on runs every deal at its default. pub(in crate::analytics::tuner) own: OwnValues, + /// The exit fields outside the model each strategy of the scope and of the selection switches + /// on (`unmodelled.rs`), read with [`Self::own`] — what a search and a write warn about. + pub(in crate::analytics::tuner) unmodelled: Arc, /// The parameter grid's rows by section (`sections::layout`), published with the rows so a /// layout never meets another scope's "now" values or kinds. pub(in crate::analytics::tuner) grid: Arc<[super::sections::GridSection]>, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs new file mode 100644 index 00000000..04473e83 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs @@ -0,0 +1,314 @@ +//! The warning about the exit fields the model does not have (`moon_core::db::tuner::ticks:: +//! unmodelled`): before a search, a dialog listing them per strategy with Continue / Cancel; in the +//! write dialog, the same list as its warning lines. Which fields and when a field counts as on is +//! the core crate's; this module only reads them per strategy at load and draws them. + +use std::collections::HashMap; +use std::sync::Arc; + +use gpui::*; +use moon_core::db::tuner::ticks::unmodelled::{UnmodelledField, unmodelled_fields}; +use moon_core::feed::strategy_deps::FieldDeps; +use moon_ui::{MoonButton, MoonButtonVariant, MoonPalette, MoonWindowExt as _, h_flex, v_flex}; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +use crate::design; +use crate::design::{moon, moon_alpha}; + +#[cfg(test)] +mod tests; + +/// The fields each strategy switches on outside the model, by `(strategy_id, core_uid)` — every +/// strategy the load read, one with none under an empty list: a strategy that is absent was not +/// read, which a write to a strategy selected after the load started meets, and says so. +pub(in crate::analytics::tuner) type UnmodelledMap = + HashMap<(i64, Option), Vec>; + +/// One strategy's line of the warning, ready to draw: its name and, per field, the field, the +/// value and the strategy window's section, and whether the model leaves its trades unjudged. +type WarnRow = (String, Vec<(String, String, String, bool)>); + +/// Read the fields off each strategy's values, once per load, off the UI thread. +/// +/// Args: +/// strategies: Every strategy the axis holds values of, by `(strategy_id, core_uid)`. +/// defaults: The live schema's numeric defaults (`strategy_field_defaults`). +/// deps: The fields' dependency rules, read for this load. +pub(super) fn unmodelled_map<'a>( + strategies: impl IntoIterator), &'a HashMap)>, + defaults: &HashMap, + deps: &FieldDeps, +) -> UnmodelledMap { + strategies + .into_iter() + .map(|(key, values)| (key, unmodelled_fields(values, defaults, deps))) + .collect() +} + +impl AnalyticsView { + /// The warning's rows for `strategies`, in the order given, a strategy once; the ones without + /// a field outside the model left out. The names of the strategies the load has not read — + /// selected after it started, or before any finished — come back beside them. + fn ticks_unmodelled_rows( + &self, + strategies: impl IntoIterator)>, + cx: &App, + ) -> (Vec, Vec) { + let empty = UnmodelledMap::new(); + let map = self + .ticks + .data + .data() + .map_or(&empty, |data| data.unmodelled.as_ref()); + let backend = self.backend.read(cx); + let human = crate::strategies::settings::human_labels(&backend.layout); + let store = backend.session.store(); + let selected: HashMap<(i64, Option), String> = self + .selected_targets() + .into_iter() + .map(|t| ((t.sid, t.core), t.name)) + .collect(); + let mut seen = Vec::new(); + let mut out = Vec::new(); + let mut unread = Vec::new(); + for key in strategies { + if seen.contains(&key) { + continue; + } + seen.push(key); + let fields = map.get(&key); + if fields.is_some_and(Vec::is_empty) { + continue; + } + let (sid, core) = key; + // The store's name first — the scope's strategies need not be selected — then the + // selection's, then the id. + let name = core + .and_then(|core| store.core(core)) + .and_then(|c| c.strategies.iter().find(|r| r.id == sid as u64)) + .map(|r| r.name.clone()) + .or_else(|| selected.get(&key).cloned()) + .unwrap_or_else(|| format!("#{sid}")); + let name = super::super::super::summary::strat_display(&name); + let Some(fields) = fields else { + unread.push(name); + continue; + }; + let fields = fields + .iter() + .map(|f| { + ( + f.key.to_string(), + f.value.clone(), + crate::strategies::sections::section_display_title( + f.section.schema_title(), + human, + ), + f.rule.is_some(), + ) + }) + .collect(); + out.push((name, fields)); + } + (out, unread) + } + + /// The strategies a search runs on — every strategy of the scope's deals — and the ones + /// selected, which a found point is written to. + fn ticks_search_strategies(&self) -> Vec<(i64, Option)> { + let mut keys: Vec<(i64, Option)> = self + .selected_targets() + .into_iter() + .map(|t| (t.sid, t.core)) + .collect(); + if let Some(data) = self.ticks.data.data() { + let mut own: Vec<(i64, Option)> = data + .own + .keys() + .map(|&(sid, core)| (sid, Some(core))) + .collect(); + own.sort_unstable(); + keys.extend(own); + } + keys + } + + /// Open the warning before a search when a strategy of the scope switches on an exit field + /// the model does not have; answers whether it opened — the search then waits for Continue. + pub(super) fn ticks_warn_before_search( + &mut self, + only: Option<&'static str>, + window: &mut Window, + cx: &mut Context, + ) -> bool { + // A search runs on the loaded scope's deals, so a strategy the load has not read is not + // one it searches: only the rows speak here. + let (rows, _) = self.ticks_unmodelled_rows(self.ticks_search_strategies(), cx); + if rows.is_empty() { + return false; + } + let rows = Arc::new(rows); + let view = cx.entity(); + window.open_unique_moon_dialog( + "an-ticks-unmodelled-dialog", + cx, + move |dialog, _window, cx| { + let p = MoonPalette::active(cx); + let rows = rows.clone(); + let go = view.clone(); + dialog + .w(design::font_w_px(cx, 520.0)) + .close_button(false) + .overlay(true) + .overlay_closable(true) + .bg(moon(p.shell_high)) + .border_color(moon(p.border)) + .rounded(design::r_container(cx)) + .text_color(moon(p.text)) + .header( + div() + .w_full() + .py_2() + .border_b_1() + .border_color(moon(p.border)) + .font_weight(FontWeight::SEMIBOLD) + .child(t!("analytics.ticks.unmodelled_title").to_string()), + ) + .content(move |content, _window, cx| content.child(warn_body(&rows, cx))) + .footer( + h_flex() + .w_full() + .justify_end() + .gap(design::ui_px(cx, 8.0)) + .font_family(design::ui_font()) + .child( + MoonButton::new("an-ticks-unmodelled-cancel") + .variant(MoonButtonVariant::Ghost) + .label(t!("dialogs.cancel").to_string()) + .on_click(|_, window, cx| window.close_dialog(cx)) + .render(), + ) + .child( + MoonButton::new("an-ticks-unmodelled-go") + .variant(MoonButtonVariant::Blue) + .label(t!("analytics.ticks.unmodelled_go").to_string()) + .on_click(move |_, window, cx| { + window.close_dialog(cx); + go.update(cx, |this, cx| this.ticks_start_search(only, cx)); + }) + .render(), + ), + ) + }, + ); + true + } + + /// The write dialog's warning lines for the strategies a write goes to: a heading and one line + /// per strategy that switches on an exit field the model does not have, when there is one; + /// then one line naming the targets the axis has not read. + pub(super) fn ticks_unmodelled_warns( + &self, + targets: &[super::super::shared::SaveTarget], + cx: &App, + ) -> Vec { + let (rows, unread) = + self.ticks_unmodelled_rows(targets.iter().map(|t| (t.sid, t.core)), cx); + let mut warns = Vec::new(); + if !rows.is_empty() { + warns.push(t!("analytics.ticks.unmodelled_save").to_string()); + } + warns.extend(rows.into_iter().map(|(name, fields)| { + let fields: Vec = fields + .into_iter() + .map(|(key, value, section, _)| format!("{key} = {value} ({section})")) + .collect(); + format!("{name}: {}", fields.join(", ")) + })); + if !unread.is_empty() { + warns.push( + t!( + "analytics.ticks.unmodelled_unread", + names = unread.join(", ") + ) + .to_string(), + ); + } + warns + } +} + +/// The dialog's body: why it asks, then each strategy with its fields as rows — field, value, +/// section — and a note on the fields whose trades the model does not judge. +fn warn_body(rows: &[WarnRow], cx: &App) -> AnyElement { + let p = MoonPalette::active(cx); + let mut list = v_flex() + .id("an-ticks-unmodelled-list") + .w_full() + .max_h(design::ui_px(cx, 360.0)) + .overflow_y_scroll() + .font_family(design::ui_font()) + .text_size(design::t_caption(cx)); + for (i, (name, fields)) in rows.iter().enumerate() { + list = list.child( + div() + .w_full() + .pt(design::ui_px(cx, if i == 0 { 2.0 } else { 8.0 })) + .pb(design::ui_px(cx, 2.0)) + .text_size(design::t_body(cx)) + .font_weight(FontWeight::SEMIBOLD) + .child(name.clone()), + ); + for (key, value, section, rule) in fields { + list = list.child( + h_flex() + .w_full() + .py(design::ui_px(cx, 2.0)) + .gap(design::ui_px(cx, 8.0)) + .border_t_1() + .border_color(moon_alpha(p.border, 0.5)) + .child( + div() + .w(design::font_w_px(cx, 150.0)) + .flex_none() + .truncate() + .child(key.clone()), + ) + .child( + div() + .w(design::font_w_px(cx, 110.0)) + .flex_none() + .truncate() + .text_color(moon(p.amber)) + .child(value.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(moon(p.text_muted)) + .child(if *rule { + format!("{section} · {}", t!("analytics.ticks.unmodelled_rule")) + } else { + section.clone() + }), + ), + ); + } + } + v_flex() + .w_full() + .gap(design::ui_px(cx, 8.0)) + .child( + div() + .w_full() + .font_family(design::ui_font()) + .text_size(design::t_body(cx)) + .text_color(moon(p.orange)) + .child(t!("analytics.ticks.unmodelled_intro").to_string()), + ) + .child(list) + .into_any_element() +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled/tests.rs new file mode 100644 index 00000000..f229a6fd --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled/tests.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; + +// Not `super::*`: the parent's `gpui::*` brings gpui's own `test` attribute, which `#[test]` would +// then name, and it expands into itself. +use super::unmodelled_map; +use moon_core::feed::strategy_deps::FieldDeps; + +fn values(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +/// Every strategy read is in the map — one with a field outside the model with it, a clean one +/// with nothing — so a strategy absent from it is one the load did not read. +#[test] +fn the_map_holds_every_strategy_read() { + let ladder = values(&[("UseSecondStop", "YES")]); + let plain = values(&[("SellPrice", "1.5"), ("IgnoreSellShot", "YES")]); + let map = unmodelled_map( + [((1, Some(7)), &ladder), ((2, Some(7)), &plain)], + &HashMap::new(), + &FieldDeps::bundled(), + ); + assert_eq!(map[&(1, Some(7))][0].key, "UseSecondStop"); + // Read and clean is not "not read": the strategy is there, with nothing to say. + assert!(map[&(2, Some(7))].is_empty()); + assert!(!map.contains_key(&(3, Some(7)))); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 25fcff96..b4babfc7 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -231,15 +231,39 @@ impl AnalyticsView { } /// "Search all": every ticked field of the groups the gate lets through, into В1. - pub(in crate::analytics::tuner) fn ticks_suggest(&mut self, cx: &mut Context) { - self.ticks_run_search(None, cx); + pub(in crate::analytics::tuner) fn ticks_suggest( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.ticks_run_search(None, window, cx); } /// "Search": the selected field alone, the rest of В1 held as it stands; the answer goes /// into that one cell of В1. - pub(in crate::analytics::tuner) fn ticks_suggest_one(&mut self, cx: &mut Context) { + pub(in crate::analytics::tuner) fn ticks_suggest_one( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { if let Some(key) = self.ticks.sel_field { - self.ticks_run_search(Some(key), cx); + self.ticks_run_search(Some(key), window, cx); + } + } + + /// A search asked for: first the warning when a strategy it runs on switches on an exit + /// field the model does not have (`unmodelled.rs`) — the search then starts on its Continue. + fn ticks_run_search( + &mut self, + only: Option<&'static str>, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(self.ticks.sugg, SuggState::Running { .. }) { + return; + } + if !self.ticks_warn_before_search(only, window, cx) { + self.ticks_start_search(only, cx); } } @@ -251,7 +275,11 @@ impl AnalyticsView { /// Run the search into В1: over every ticked field (`only` = `None`), or over one field with /// every other held — at the strategies' value, or at В1's where В1 changes it. - fn ticks_run_search(&mut self, only: Option<&'static str>, cx: &mut Context) { + pub(super) fn ticks_start_search( + &mut self, + only: Option<&'static str>, + cx: &mut Context, + ) { if matches!(self.ticks.sugg, SuggState::Running { .. }) { return; } @@ -422,7 +450,8 @@ impl AnalyticsView { log::info!("analytics: 'Save' (ticks) - no variant to write"); return; } - let warns = self.ticks_change_warnings(&changes, cx); + let mut warns = self.ticks_change_warnings(&changes, cx); + warns.extend(self.ticks_unmodelled_warns(&targets, cx)); self.open_change_dialog(targets, changes, None, Vec::new(), warns, false, cx); } @@ -436,7 +465,8 @@ impl AnalyticsView { return; }; let changes = self.ticks.variant_changes(0); - let warns = self.ticks_change_warnings(&changes, cx); + let mut warns = self.ticks_change_warnings(&changes, cx); + warns.extend(self.ticks_unmodelled_warns(std::slice::from_ref(&target), cx)); self.open_copy_with(target, changes, warns, window, cx); } diff --git a/crates/moon-ui-gpui/src/strategies/rules.rs b/crates/moon-ui-gpui/src/strategies/rules.rs index adec9b2a..2cd8e469 100644 --- a/crates/moon-ui-gpui/src/strategies/rules.rs +++ b/crates/moon-ui-gpui/src/strategies/rules.rs @@ -1,49 +1,23 @@ //! Strategy-field dependency rules that determine whether a field is editable or a section is //! active from the values of OTHER fields. Rules come from `assets/param_deps.toml` -//! (`"Field" = "A=VAL;B<>VAL"`). Startup first tries the external development path, then uses the -//! bundled fallback. External-file hot reload requires the explicit -//! `MOON_STRATEGY_RULES_HOT_RELOAD` environment variable; production does not poll the filesystem -//! every second. Field names and conditions are parsed on initial load and each external reload. -//! This is a verbatim port of egui's `src/strategies/rules.rs`. +//! (`"Field" = "A=VAL;B<>VAL"`), parsed and evaluated by [`FieldDeps`] in the core crate, where a +//! model without this window reads them too. This module keeps the window's loading: startup +//! first tries the external development path, then uses the bundled fallback. External-file hot +//! reload requires the explicit `MOON_STRATEGY_RULES_HOT_RELOAD` environment variable; production +//! does not poll the filesystem every second. //! //! Section activity has no separate configuration: `section_active` evaluates these dependencies //! and keeps a section active when more than one of its fields is dependency-active. -use std::collections::HashMap; use std::time::SystemTime; -/// External path relative to the cwd for development hot reload (`cargo run` uses the workspace root). -const EXTERNAL: &str = "assets/param_deps.toml"; -/// Fallback bundled into the binary for release runs without adjacent assets. -const BUNDLED: &str = include_str!("../../../../assets/param_deps.toml"); +use moon_core::feed::strategy_deps::{EXTERNAL, FieldDeps}; -/// Effective dependency values keyed by lowercase field name. -/// -/// Stored fields are overlaid with staged edits, while schema defaults fill omitted fields. -pub type Values = HashMap; - -/// Dependency-condition operator. -#[derive(Clone, Copy)] -enum Op { - Eq, - Ne, - Gt, - Lt, - Ge, - Le, -} - -/// One dependency condition: `field` (op) `value`. -#[derive(Clone)] -struct Cond { - field: String, - op: Op, - value: String, -} +pub use moon_core::feed::strategy_deps::Values; pub struct Rules { - /// Lowercase field name mapped to conditions joined by `;` as logical AND. - deps: HashMap>, + /// The parsed rules. + deps: FieldDeps, /// Modification time of the external file, used for hot reload. mtime: Option, } @@ -51,117 +25,47 @@ pub struct Rules { impl Rules { /// Load rules from the external file when present, otherwise from the bundled fallback. pub fn load() -> Self { - let mut r = Rules { - deps: HashMap::new(), - mtime: None, + let rules = match std::fs::read_to_string(EXTERNAL) { + Ok(content) => Rules { + deps: FieldDeps::parse(&content), + mtime: file_mtime(), + }, + Err(_) => Rules { + deps: FieldDeps::bundled(), + mtime: None, + }, }; - match std::fs::read_to_string(EXTERNAL) { - Ok(content) => { - r.mtime = file_mtime(); - r.parse_into(&content); - } - Err(_) => r.parse_into(BUNDLED), - } - r - } - - /// Reload the external file when it changes, returning true when a new frame is needed. - pub fn reload_if_changed(&mut self) -> bool { - let m = file_mtime(); - if m.is_some() && m != self.mtime { - if let Ok(content) = std::fs::read_to_string(EXTERNAL) { - self.mtime = m; - self.deps.clear(); - self.parse_into(&content); - return true; - } - } - false + rules.log_count(); + rules } - /// Parse manually edited `"Field" = "condition"` entries line by line. - /// - /// This accepts duplicate keys (the last wins), full-line `#` comments, the `[deps]` header, - /// and quotes. - /// Unlike strict TOML, one malformed key does not invalidate the entire file. - fn parse_into(&mut self, content: &str) { - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with('[') { - continue; - } - // Split on the FIRST `=`; any `=` inside the value follows the closing key quote. - let Some(eq) = line.find('=') else { continue }; - let key = line[..eq].trim().trim_matches('"').trim().to_lowercase(); - let expr = line[eq + 1..].trim().trim_matches('"').trim(); - if key.is_empty() { - continue; - } - self.deps.insert(key, parse_conds(expr)); - } + /// One line per load, as the window always logged it. + fn log_count(&self) { log::info!( - "strategy param rules: {} полей с зависимостями", + "strategy param rules: {} fields with dependencies", self.deps.len() ); } - /// Return whether a field is active and editable under the current values. - /// - /// Every condition must hold; a field without a rule is active. A condition referring to a - /// field absent from `values` is inapplicable because that field does not exist for this - /// strategy kind, so it does not block. `selected_values` inserts every schema field with its - /// default or an empty value, making absence mean "not part of this kind" while an unsaved - /// field is still compared using its default. - pub fn field_active(&self, name: &str, values: &Values) -> bool { - match self.deps.get(&name.to_lowercase()) { - None => true, - Some(conds) => conds.iter().all(|c| match values.get(&c.field) { - None => true, - Some(v) => cond_true(c, v), - }), + /// Reload the external file when it changes, returning true when a new frame is needed. + pub fn reload_if_changed(&mut self) -> bool { + let m = file_mtime(); + if m.is_some() + && m != self.mtime + && let Ok(content) = std::fs::read_to_string(EXTERNAL) + { + self.mtime = m; + self.deps = FieldDeps::parse(&content); + self.log_count(); + return true; } + false } -} - -/// Evaluate condition `c` against value `v`. -/// -/// `=` and `<>` compare booleans or strings; `>`, `<`, `>=`, and `<=` compare numbers. A -/// nonnumeric operand makes a numeric condition false. -fn cond_true(c: &Cond, v: &str) -> bool { - match c.op { - Op::Eq => value_eq(v, &c.value), - Op::Ne => !value_eq(v, &c.value), - _ => match (v.trim().parse::(), c.value.trim().parse::()) { - (Ok(a), Ok(e)) => match c.op { - Op::Gt => a > e, - Op::Lt => a < e, - Op::Ge => a >= e, - Op::Le => a <= e, - _ => true, - }, - _ => false, - }, - } -} - -/// Interpret the core's boolean forms: `1/0`, `Yes/No`, and `true/false`. -/// -/// None means the value is a number or string rather than a boolean. -fn as_bool(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "yes" | "true" | "1" | "on" => Some(true), - "no" | "false" | "0" | "off" | "" => Some(false), - _ => None, - } -} -/// Compare condition values as booleans when BOTH sides are boolean forms, including `0/1`. -/// -/// This makes `IgnoreVolume=NO` match the raw value `"0"`; all other values compare as strings. -fn value_eq(actual: &str, expected: &str) -> bool { - match (as_bool(actual), as_bool(expected)) { - (Some(a), Some(e)) => a == e, - _ => actual.eq_ignore_ascii_case(expected), + /// Return whether a field is active and editable under the current values + /// ([`FieldDeps::field_active`]). + pub fn field_active(&self, name: &str, values: &Values) -> bool { + self.deps.field_active(name, values) } } @@ -171,32 +75,3 @@ fn file_mtime() -> Option { .ok() .and_then(|m| m.modified().ok()) } - -/// Parse `A=VAL;B<>VAL;C>1` into lowercase field/value conditions. -/// -/// Operators are checked longest first: `<>`, `>=`, and `<=` precede `>`, `<`, and `=`. -fn parse_conds(expr: &str) -> Vec { - const OPS: [(&str, Op); 6] = [ - ("<>", Op::Ne), - (">=", Op::Ge), - ("<=", Op::Le), - (">", Op::Gt), - ("<", Op::Lt), - ("=", Op::Eq), - ]; - expr.split(';') - .filter_map(|part| { - let part = part.trim(); - if part.is_empty() { - return None; - } - OPS.iter().find_map(|&(s, op)| { - part.find(s).map(|i| Cond { - field: part[..i].trim().to_lowercase(), - op, - value: part[i + s.len()..].trim().to_lowercase(), - }) - }) - }) - .collect() -} diff --git a/locales/analytics.yml b/locales/analytics.yml index 627eef95..d5184855 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1905,9 +1905,9 @@ analytics.ticks.params_title: en: "Parameters" es: "Parámetros" analytics.ticks.assumptions: - ru: "Модель не знает: стакан и очередь на уровне · правила выхода вне модели (лестница стопов) · MShotRepeat* · опора ASK/BID — последний принт своей стороны" - en: "The model does not know: the book and the queue at a level · exit rules outside it (the stop ladder) · MShotRepeat* · the ASK/BID reference is the last print of its side" - es: "El modelo no conoce: el libro y la cola en un nivel · reglas de salida fuera de él (escalera de stops) · MShotRepeat* · la referencia ASK/BID es el último print de su lado" + ru: "Модель не знает: стакан и очередь на уровне · правила выхода вне модели (лестница стопов, SellShot, SellSpread) · MShotRepeat* · опора ASK/BID — последний принт своей стороны" + en: "The model does not know: the book and the queue at a level · exit rules outside it (the stop ladder, SellShot, SellSpread) · MShotRepeat* · the ASK/BID reference is the last print of its side" + es: "El modelo no conoce: el libro y la cola en un nivel · reglas de salida fuera de él (escalera de stops, SellShot, SellSpread) · MShotRepeat* · la referencia ASK/BID es el último print de su lado" analytics.ticks.group_entry: ru: "Вход" en: "Entry" @@ -1936,6 +1936,38 @@ analytics.ticks.row_outside: ru: "Модель это поле пока не учитывает" en: "The model does not take this field into account yet" es: "El modelo aún no tiene en cuenta este campo" +analytics.ticks.row_unmodelled: + ru: "Раздел не моделируется — поле модель не учитывает" + en: "The section is not modelled — the model does not take this field into account" + es: "La sección no se modela — el modelo no tiene en cuenta este campo" +analytics.ticks.section_unmodelled: + ru: "не моделируется: сделки стратегий с включённым разделом не судятся и в подбор не идут" + en: "not modelled: trades of a strategy that switches it on are not judged and stay out of the search" + es: "no se modela: las operaciones de una estrategia que lo activa no se juzgan y quedan fuera de la búsqueda" +analytics.ticks.unmodelled_title: + ru: "Параметры выхода, которых нет в модели" + en: "Exit parameters the model does not have" + es: "Parámetros de salida que el modelo no tiene" +analytics.ticks.unmodelled_intro: + ru: "Подбор идёт без учёта этих параметров. Если они останутся включены в стратегии, её выход будет не таким, как посчитал подбор, — результат непредсказуем." + en: "The search runs without these parameters. If they stay on in the strategy, it will not exit the way the search counted — the result is unpredictable." + es: "La búsqueda se hace sin estos parámetros. Si siguen activos en la estrategia, no saldrá como calculó la búsqueda — el resultado es impredecible." +analytics.ticks.unmodelled_rule: + ru: "сделки не судятся" + en: "trades not judged" + es: "operaciones no juzgadas" +analytics.ticks.unmodelled_go: + ru: "Подобрать всё равно" + en: "Search anyway" + es: "Buscar de todos modos" +analytics.ticks.unmodelled_unread: + ru: "Не проверены на параметры выхода вне модели — ось их не прочла (ещё грузится или не загрузилась): %{names}" + en: "Not checked for exit parameters outside the model — the axis has not read them (still loading, or the load failed): %{names}" + es: "Sin comprobar los parámetros de salida fuera del modelo — el eje no los ha leído (aún se está cargando o la carga falló): %{names}" +analytics.ticks.unmodelled_save: + ru: "Включены параметры выхода, которых нет в модели: подбор шёл без них, и с ними выход стратегии непредсказуем." + en: "Exit parameters the model does not have are on: the search ran without them, and with them the strategy's exit is unpredictable." + es: "Hay parámetros de salida activos que el modelo no tiene: la búsqueda se hizo sin ellos y con ellos la salida de la estrategia es impredecible." analytics.ticks.no_deals: ru: "в выборке нет сделок с мс-штампами" en: "no trades with millisecond stamps in the scope" From 04d7c96ff770dcf36118127b76d2075f44c086d3 Mon Sep 17 00:00:00 2001 From: guyverino Date: Thu, 24 Sep 2026 18:41:57 +0200 Subject: [PATCH 36/51] feat(tuner): stop ladder and Stops knobs, honest search, search survives 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. --- crates/moon-core/src/db/tuner/ticks/exit.rs | 17 +- .../src/db/tuner/ticks/exit/delta_mods.rs | 10 +- .../db/tuner/ticks/exit/delta_mods/tests.rs | 46 +++++ .../moon-core/src/db/tuner/ticks/exit/line.rs | 33 ++- .../src/db/tuner/ticks/exit/sell_order.rs | 36 +++- .../db/tuner/ticks/exit/sell_order/tests.rs | 63 ++++++ .../src/db/tuner/ticks/exit/stops.rs | 78 ++++++- .../src/db/tuner/ticks/exit/stops/ladder.rs | 156 ++++++++++++++ .../db/tuner/ticks/exit/stops/ladder/tests.rs | 195 ++++++++++++++++++ crates/moon-core/src/db/tuner/ticks/mod.rs | 8 + crates/moon-core/src/db/tuner/ticks/mshot.rs | 65 ++++-- .../src/db/tuner/ticks/mshot/tests.rs | 50 +++++ crates/moon-core/src/db/tuner/ticks/params.rs | 92 +++++++-- crates/moon-core/src/db/tuner/ticks/record.rs | 9 +- crates/moon-core/src/db/tuner/ticks/search.rs | 127 ++++++++---- .../src/db/tuner/ticks/search/closing.rs | 93 +++++++++ .../db/tuner/ticks/search/closing/tests.rs | 92 +++++++++ .../src/db/tuner/ticks/search/deps.rs | 139 +++++++++++++ .../src/db/tuner/ticks/search/deps/tests.rs | 113 ++++++++++ .../src/db/tuner/ticks/search/tests.rs | 8 +- crates/moon-core/src/db/tuner/ticks/tests.rs | 42 ++-- .../src/db/tuner/ticks/unmodelled.rs | 21 +- .../src/db/tuner/ticks/unmodelled/tests.rs | 78 ++++--- crates/moon-core/src/db/tuner/ticks/verify.rs | 24 ++- .../moon-core/tests/diagnostics_contract.rs | 4 + crates/moon-ui-gpui/src/analytics/bg.rs | 39 ++-- crates/moon-ui-gpui/src/analytics/mod.rs | 12 +- .../moon-ui-gpui/src/analytics/tuner/mod.rs | 55 ++++- .../src/analytics/tuner/ticks/cfg.rs | 1 + .../src/analytics/tuner/ticks/grid.rs | 26 ++- .../src/analytics/tuner/ticks/load.rs | 4 +- .../src/analytics/tuner/ticks/mod.rs | 8 +- .../src/analytics/tuner/ticks/rows/tests.rs | 1 + .../analytics/tuner/ticks/sections/tests.rs | 18 +- .../src/analytics/tuner/ticks/state.rs | 42 +++- .../analytics/tuner/ticks/unmodelled/tests.rs | 6 +- .../src/analytics/tuner/ticks/variants.rs | 117 ++++++++++- .../analytics/tuner/ticks/variants/probe.rs | 120 +++++++++++ .../tests/theme_contract/analytics.rs | 16 +- locales/analytics.yml | 26 ++- 40 files changed, 1842 insertions(+), 248 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/delta_mods/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/exit/stops/ladder/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/mshot/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/closing.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/deps.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/probe.rs diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index 7a53f7ca..816c154b 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -23,6 +23,8 @@ pub mod pump_move; pub mod sell_order; pub mod stops; +pub use self::stops::ladder::StopStep; + use self::line::{LineWalk, walk, walk_held}; use super::mshot::Modifiers; use super::settings::ModelSettings; @@ -154,8 +156,10 @@ pub struct ExitParams { /// kinds that do not set the field did not move. pub sell_modifier: f64, /// `MaxModifier` — ceiling on the summed modifiers BEFORE the coefficient: - /// `Min(MaxModifier, Σ Pn · Dn)`. 0 means no ceiling. Live strategies keep it around 70 % - /// (median of 526 that set it), so it rarely binds. + /// `Min(MaxModifier, |Σ Pn · Dn|)` — the core caps the sum's magnitude, so the sum is never + /// negative (`exit::delta_mods::modifier_sum`). 0 means no ceiling. Live strategies keep it + /// around 70 % (median of 526 that set it), so it rarely binds. The same field caps the + /// MoonShot corridor's `MShotAdd*` sum (`MshotParams::max_modifier`). pub max_modifier: f64, /// `StopLossModifier` — the same summed modifiers, applied to the STOP instead of the sell: /// the stop goes DEEPER by `StopLossModifier · Σ`, as the core's FAQ spells it: @@ -214,6 +218,11 @@ pub struct ExitParams { /// buy, NOT the order's `SellPrice`: no line until the middle passed it by `|TrailingPercent|`, /// and no sale below it. `None` when it is off. pub trailing_take_profit_pct: Option, + /// The second stop (`UseSecondStop` and its three fields), `None` when it is off or there is + /// no stop to move (`exit::stops::ladder`). + pub second_stop: Option, + /// The third stop (`UseStopLoss3` and its three fields), likewise. + pub third_stop: Option, /// A sell rule the strategy switched on that the model does not have. The walk runs as if /// it were off, and the verdict answers nothing for such a trade, which keeps it out of the /// search (`record::fit_for_search`): a variant's exit there is whatever the missing rule @@ -268,6 +277,8 @@ impl Default for ExitParams { trailing_pct: 0.0, trailing_ema: 0.0, trailing_take_profit_pct: None, + second_stop: None, + third_stop: None, unmodelled: None, model: ModelSettings::default(), take_from_archive: false, @@ -278,8 +289,6 @@ impl Default for ExitParams { /// A sell rule the strategy can switch on that the model does not have. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum UnmodelledRule { - /// `UseSecondStop` / `UseStopLoss3` — the stop ladder. - StopLadder, /// `IgnoreSellShot` off with a `SellShotDistance` — the sell kept at a distance from the /// market's high. SellShot, diff --git a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs index b44c4452..c25a3d9b 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs @@ -13,7 +13,10 @@ impl ExitModel<'_> { } } -/// The summed delta modifiers of a trade, capped: `Min(MaxModifier, Σ Pn · Dn)`. +/// The summed delta modifiers of a trade, capped: `Min(MaxModifier, |Σ Pn · Dn|)` — the core +/// takes the sum's magnitude and caps it when `MaxModifier` is above zero (the core developer via +/// LinKvo, 2026-09-24), so the sum is never negative: only a negative coefficient moves a level +/// toward the entry. /// /// One sum, two consumers — the sell level through `SellModifier` and the stop through /// `StopLossModifier` — because the core computes it once and spends it on both (FAQ). @@ -30,10 +33,13 @@ impl ExitModel<'_> { /// deal: The trade, for its deltas. /// at_ms: When the sell was placed — the fill. pub fn modifier_sum(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { - let sum = params.sell_mods.near_addition(&deal.deltas_at(at_ms)); + let sum = params.sell_mods.near_addition(&deal.deltas_at(at_ms)).abs(); if params.max_modifier > 0.0 { sum.min(params.max_modifier) } else { sum } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods/tests.rs new file mode 100644 index 00000000..882f0298 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods/tests.rs @@ -0,0 +1,46 @@ +//! The Delta Modifiers tab's sum as the core forms it (the core developer via LinKvo, 2026-09-24). + +use super::*; +use crate::db::tuner::ticks::exit::tests::deal; +use crate::db::tuner::ticks::mshot::{MarketSign, Modifiers}; + +/// The market and the BTC deltas are read as magnitudes in the sell family; the corridor family +/// keeps their sign. +#[test] +fn the_sell_family_reads_btc_as_a_magnitude() { + let mut d = deal(false).deltas; + d.btc1h = -3.0; + d.btc5m = -1.0; + d.market1h = -2.0; + let family = |market_sign| Modifiers { + add_btc_1h: 1.0, + add_btc_5m: 1.0, + add_market_1h: 1.0, + market_sign, + ..Modifiers::default() + }; + assert!((family(MarketSign::Magnitude).near_addition(&d) - 6.0).abs() < 1e-9); + assert!((family(MarketSign::Signed).near_addition(&d) - -6.0).abs() < 1e-9); +} + +/// The sum is a magnitude, capped from above by `MaxModifier` when it is set: a falling coin +/// moves the levels the same way a rising one does. +#[test] +fn the_sum_is_a_capped_magnitude() { + let mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; + let p = ExitParams { + sell_mods: mods, + ..ExitParams::default() + }; + let mut d = deal(false); + d.deltas.d1h = -5.0; + assert!((modifier_sum(&p, &d, d.buy_ms) - 5.0).abs() < 1e-9); + let capped = ExitParams { + max_modifier: 2.0, + ..p + }; + assert!((modifier_sum(&capped, &d, d.buy_ms) - 2.0).abs() < 1e-9); +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/line.rs b/crates/moon-core/src/db/tuner/ticks/exit/line.rs index f3959c85..8c9f26de 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/line.rs @@ -34,6 +34,9 @@ pub struct LinePoint { pub struct LineWalk { pub exit: Exit, pub points: Vec, + /// Where the stop stood when the walk ended — the ladder's last step, else the first stop's + /// level; `None` without a stop. What the verdict holds against the level the core printed. + pub stop_level: Option, } /// One step of the core's sell price: `level` is what a rule computed, `order` that level on @@ -173,6 +176,7 @@ impl Line { LineWalk { exit, points: self.points, + stop_level: None, } } } @@ -226,7 +230,7 @@ pub fn walk_held( // The fact's own stop fired between the last print and this one: ahead of anything // this print does, and after everything the prints before it did. if let Some(exit) = stops.fired_by(t_ms) { - return line.close(exit); + return finish(line, exit, &stops); } // The timer-driven rules moved the line at their own moments, between prints; every // step due by this print happened BEFORE it, and a step that also reached the book @@ -257,7 +261,7 @@ pub fn walk_held( line.land(t_ms); // The stops come before the print-driven rule below moves anything. if let Some(exit) = stops.on_print(tick, t_ms, price) { - return line.close(exit); + return finish(line, exit, &stops); } // A print AT the level fills the sell — the optimistic reading the spec states (§7: // the queue standing at the level is not modelled; COOL 2026-09-21 printed 31 @@ -273,22 +277,33 @@ pub fn walk_held( let held = hold_until_ms.is_some_and(|until| t_ms <= until); if !held { if let Some(exit) = line.fill_on(t_ms, price, side) { - return line.close(exit); + return finish(line, exit, &stops); } } } let tail = ticks.last().map(|t| t.time_ms as i64).unwrap_or(last_t); // The fact's own stop past the last print, or the book stop's samples up to the tape's end. if let Some(exit) = stops.after_tape(tail) { - return line.close(exit); + return finish(line, exit, &stops); } // Nothing closed it inside the tape. Not the report's own exit: a variant that never // closes is not a trade, whatever the core's rules did, and the caption counts it. - line.close(Exit { - t_ms: tail, - price: f64::NAN, - kind: ExitKind::OpenAtWindowEnd, - }) + finish( + line, + Exit { + t_ms: tail, + price: f64::NAN, + kind: ExitKind::OpenAtWindowEnd, + }, + &stops, + ) +} + +/// Close the line on `exit`, with the stop's level as it then stood. +fn finish(line: Line, exit: Exit, stops: &Stops) -> LineWalk { + let mut walked = line.close(exit); + walked.stop_level = stops.level(); + walked } #[cfg(test)] diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs index 828f0d45..759c1f34 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs @@ -56,18 +56,13 @@ impl ExitModel<'_> { return take; } } - // Floored at zero: a modifier deep enough to drive the distance negative would put the - // TAKE on the losing side of the entry and turn every level the line steps down from - // inside out. The rules that legitimately sell below the entry are the moving ones - // (`PriceDownAllowedDrop`), and they get there by - // stepping down from the take, not by starting underneath it. - let pct = (self.base_take_pct(deal) + self.modifier_pct(deal, fill.t_ms)).max(0.0); // A short's take divides off the fill for every kind (the core developer, 2026-09-23 for - // MoonShot, 2026-09-24 for every per cent of a short off the buy). A MoonHook's take - // carries the delta modifiers, whose live sum the report does not keep, so the archive - // cannot tell the two readings apart on it; the stop and the `PriceDownAllowedDrop` - // floor, which it can, divide. - let mut take = level_off_buy(fill.price, pct, deal.is_long()); + // MoonShot, 2026-09-24 for every per cent of a short off the buy). + let mut take = level_off_buy( + fill.price, + self.base_take_pct(deal).max(0.0), + deal.is_long(), + ); if self.params.sell_at_last_price { let pre = deal .pre_spike_ask @@ -83,6 +78,25 @@ impl ExitModel<'_> { }; } } + // Then the delta modifiers move the sell so placed — by `SellModifier · Σ` per cent of + // its own price, a short's by a division as every short per cent — after the floor of + // `SellPrice` over the buy and after the lift to the ask, so a negative coefficient can + // take it under that floor (the core developer via LinKvo, 2026-09-24; the core's own + // "SellPrice adjusted by +0.30 * +2.30% = +0.69% (0.00056430 => 0.00056044)" is a short's + // 0.00056430 / 1.0069). + // Under the floor, but not through the fill: a shift deep enough to carry the sell past + // the entry — toward zero, a short's toward infinity at −100 % — is held AT the entry. + // What the core does there is not known (no trade of the live sample reaches it), and a + // take on the losing side would turn every level the line steps from inside out. + let shift = self.modifier_pct(deal, fill.t_ms); + if shift.is_finite() && shift != 0.0 { + let shifted = level_off_buy(take, shift.max(-99.0), deal.is_long()); + take = if deal.is_long() { + shifted.max(fill.price.min(take)) + } else { + shifted.min(fill.price.max(take)) + }; + } take } diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs index 5cba8d63..c4bcdafb 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs @@ -301,3 +301,66 @@ fn sell_level_relative_takes_a_share_of_the_distance_to_the_buy() { let w = walk(&deal(false), &ticks, fill(), 120.0, &p); assert!((w.points[1].price - 105.0).abs() < 1e-9, "{:?}", w.points); } + +// ---- the delta modifiers' shift --------------------------------------------------------------- + +/// The delta modifiers move the sell as placed — after the `SellPrice` floor and the lift to the +/// pre-spike ask — by `SellModifier · Σ` per cent of its own price, a short's by a division +/// (the core developer via LinKvo, 2026-09-24; the core's "SellPrice adjusted by +0.30 * +2.30% = +/// +0.69% (0.00056430 => 0.00056044)" is a short's 0.00056430 / 1.0069). +#[test] +fn the_sell_shift_follows_the_lift_and_divides_for_a_short() { + let mods = crate::db::tuner::ticks::mshot::Modifiers { + add_1h: 1.0, + ..Default::default() + }; + let p = ExitParams { + sell_price_pct: 1.0, + sell_modifier: 0.2, + sell_mods: mods, + sell_at_last_price: true, + ..params() + }; + let mut d = deal(false); + d.deltas.d1h = 5.0; + d.pre_spike_ask = Some(103.0); + // Lifted to the ask's 103 (above the 101 of `SellPrice`), then 5 % · 0.2 = 1 % off that. + let take = ExitModel::new(&p).take_level(&d, &[], fill()); + assert!((take - 104.03).abs() < 1e-9, "{take}"); + let short = ExitParams { + sell_at_last_price: false, + ..p + }; + let mut d = deal(true); + d.deltas.d1h = 5.0; + // 100 / 1.01 for `SellPrice`, then that over 1.01 again for the shift. + let take = ExitModel::new(&short).take_level(&d, &[], fill()); + assert!((take - 100.0 / 1.01 / 1.01).abs() < 1e-9, "{take}"); +} + +/// A shift deep enough to carry the sell through the entry — toward zero, a short's toward +/// infinity — is held at the entry, with no step at −100 %. +#[test] +fn a_shift_through_the_entry_is_held_at_the_entry() { + let mods = crate::db::tuner::ticks::mshot::Modifiers { + add_1h: 1.0, + ..Default::default() + }; + let p = ExitParams { + sell_price_pct: 1.0, + sell_modifier: -0.5, + sell_mods: mods, + ..params() + }; + for short in [false, true] { + for d1h in [150.0, 198.0, 300.0] { + let mut d = deal(short); + d.deltas.d1h = d1h; + let take = ExitModel::new(&p).take_level(&d, &[], fill()); + assert!( + (take - 100.0).abs() < 1e-9, + "short {short}, Σ {d1h}: {take}" + ); + } + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops.rs index f3bcf76e..8323bc7c 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/stops.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops.rs @@ -14,10 +14,12 @@ //! fact is judged. //! //! The trailing stop (`UseTrailing`) is [`trailing`]; the stop ladder (`UseSecondStop`, -//! `UseStopLoss3`) is not modelled ([`super::UnmodelledRule`]). +//! `UseStopLoss3`) is [`ladder`], which moves the level this stop fires at. +pub mod ladder; pub mod trailing; +use self::ladder::Ladder; use self::trailing::Trailing; use super::delta_mods::modifier_sum; use super::{ExitParams, Side}; @@ -43,9 +45,9 @@ pub const SERIES_TICK_MS: i64 = 250; /// The stop distance of a trade, per cent: `StopLoss` adjusted by `StopLossModifier · Σ`. /// -/// Normally that deepens the stop (a positive coefficient over a positive delta sum), but -/// neither sign is guaranteed: live strategies carry `StopLossModifier` down to −0.3, and a -/// delta sum can be negative, so the adjustment can also pull the stop TOWARD the entry. +/// The sum is never negative (the core takes its magnitude, [`modifier_sum`]), so a positive +/// coefficient deepens the stop; but live strategies carry `StopLossModifier` down to −0.3, and +/// that pulls the stop TOWARD the entry. /// /// An adjustment big enough to pull it THROUGH the entry answers `0.0` — no stop on this trade /// — rather than a level. Clamping it to a hair's breadth from the entry instead would fire on @@ -295,6 +297,8 @@ enum Trigger { /// stop beside it. pub(super) struct Stops { trigger: Trigger, + /// The second and third stops, when the strategy switched one on and there is a stop to move. + ladder: Option, /// `UseTrailing`'s line, when the strategy switched it on. trailing: Option, /// When and at what price the fact's own stop fired, when the walk runs the trade's own. @@ -385,18 +389,68 @@ impl Stops { } Trigger::Book(book) }; + // The ladder moves a stop there is: none without one (`UseStopLoss` off, the fields hang + // on it — param_deps.toml — or an adjustment that cancelled it). + let ladder = stop_on + .then(|| { + Ladder::new( + side.long, + fill.price, + fill.t_ms, + super::sell_order::armed_at(fill, params), + stop_from, + first_ms, + params.model.ticker_period_ms, + &[params.second_stop, params.third_stop], + ) + }) + .flatten() + .map(|mut ladder| { + for tick in before_fill() { + ladder.see(tick); + } + ladder + }); Self { trigger, + ladder, trailing, fired, } } + /// Where the stop stands now — the ladder's last step, else the first stop's level; `None` + /// without a stop. + pub(super) fn level(&self) -> Option { + match &self.trigger { + Trigger::Off => None, + Trigger::Fast { level, .. } => Some(*level), + Trigger::Book(book) => Some(book.level), + } + } + + /// The ladder's steps due before `until`, moving the stop's level when one is taken. The step + /// is read on the ticker's arrivals before the print, and the stop's own arrivals up to it + /// then read the new level — within one gap between prints, the order of the two is not + /// kept. + fn climb(&mut self, until: i64) { + let Some(level) = self.ladder.as_mut().and_then(|ladder| ladder.before(until)) else { + return; + }; + match &mut self.trigger { + Trigger::Off => {} + Trigger::Fast { level: at, .. } => *at = level, + Trigger::Book(book) => book.level = level, + } + } + /// The fact's own stop, when it fired by the print at `t_ms`. - pub(super) fn fired_by(&self, t_ms: i64) -> Option { - self.fired - .filter(|(at, _)| t_ms >= *at) - .map(|(at, sold)| stop_exit(at, sold)) + /// The ladder is climbed up to that moment — not past it — so the level the walk then + /// reports is the one the fact's stop fired at. + pub(super) fn fired_by(&mut self, t_ms: i64) -> Option { + let (at, sold) = self.fired.filter(|(at, _)| t_ms >= *at)?; + self.climb(at + 1); + Some(stop_exit(at, sold)) } /// The stop on the print at `t_ms`, `price`: the book stop's and the trailing's ticker @@ -405,6 +459,7 @@ impl Stops { /// moment, the stop first on the same moment (the core checks it first) — or the fast stop, a /// market order the core fires on the print. pub(super) fn on_print(&mut self, tick: &Tick, t_ms: i64, price: f64) -> Option { + self.climb(t_ms); let by_book = match &mut self.trigger { Trigger::Book(book) => book.before(t_ms), Trigger::Off | Trigger::Fast { .. } => None, @@ -422,6 +477,9 @@ impl Stops { if let Some(trailing) = self.trailing.as_mut() { trailing.see(tick); } + if let Some(ladder) = self.ladder.as_mut() { + ladder.see(tick); + } match &self.trigger { Trigger::Fast { long, @@ -439,9 +497,13 @@ impl Stops { /// included, reading the proxies the last prints left; the walk only ever reaches the samples /// before a print. pub(super) fn after_tape(&mut self, tail: i64) -> Option { + // The fact's own stop, with the ladder climbed to its moment — often past the tape's end, + // on the last bid the prints left. if let Some((at, sold)) = self.fired { + self.climb(at + 1); return Some(stop_exit(at, sold)); } + self.climb(tail + 1); let by_book = match &mut self.trigger { Trigger::Book(book) => book.before(tail + 1), Trigger::Off | Trigger::Fast { .. } => None, diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs new file mode 100644 index 00000000..fb9703d1 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs @@ -0,0 +1,156 @@ +//! The stop ladder of the strategy window's "Stops" section: the second stop (`UseSecondStop`, +//! `TimeToSwitch2Stop`, `PriceToSwitch2Stop`, `SecondStopLoss`) and the third (`UseStopLoss3`, +//! `TimeToSwitchStop3`, `PriceToSwitchStop3`, `StopLoss3`) — one rule, two steps. +//! +//! From the FAQ (1228–1235) and the Stops tab of moonbot.pro: "if after `TimeToSwitch2Stop` +//! seconds or more the price is above `PriceToSwitch2Stop`, the stop line moves to the second +//! stop's line" — the prices per cent off the buy. From the core's answers of 2026-09-24 +//! (`docs-internal/STRATEGY_FORMULAS/sell-common.md` §7 and `STOPS_QUESTIONS_2.md`): +//! +//! - the condition is read off the BID of the REST ticker, a short's as well, and a short's per +//! cents off the buy divide; +//! - the time counts from the SELL's placement (a MoonShot's after the full fill), its seconds +//! rounded as usual — `TimeToSwitchStop3 = 60` holds from 60.5 s; +//! - `StopLossDelay` holds the ladder as it holds the stop: nothing is read before the delay's +//! end, and the first read is the first check after it — a bid back under the switch price by +//! then takes nothing; +//! - both steps are checked on every cycle, the second first; each is taken once and sets its own +//! level as it is, not against the one in force, so the step taken LAST decides — the third on +//! a cycle both are taken, and a second taken after the third overwrites it; +//! - the moved stop fires by the same check as the first one (`StopLossEMA` / `FastStopLoss`). +//! +//! From the report (6 364 ladder stops, 2026-09-24): the level a step moves to is its field off +//! the buy, `StopLossModifier` left out — 939 of 939 stops fired at a second stop's level print +//! exactly that; and every one fired with the price back under the switch price, so a step once +//! taken stays. +//! +//! The tape carries no book, so the bid is read the way the book stop reads it: the last taker +//! sell stands for it, sampled on the ticker's clock ([`super::TICKER_PERIOD_MS`]). + +use super::super::level_off_buy; +use crate::feed::types::{Side as TickSide, Tick}; + +/// One step of the ladder, in the strategy's own units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StopStep { + /// `TimeToSwitch2Stop` / `TimeToSwitchStop3` — whole seconds after the sell's placement; the + /// core rounds the elapsed seconds, so the step may be taken from half a second past it. + pub after_s: f64, + /// `PriceToSwitch2Stop` / `PriceToSwitchStop3` — per cent off the buy the bid must be past. + pub switch_pct: f64, + /// `SecondStopLoss` / `StopLoss3` — per cent off the buy the stop moves to. + pub level_pct: f64, +} + +/// A step as the walk runs it: its moment, prices and whether it was taken. +struct Rung { + from_ms: i64, + switch_at: f64, + level: f64, + taken: bool, +} + +/// The ladder as the walk runs it. +pub(super) struct Ladder { + long: bool, + rungs: Vec, + /// The bid as the prints so far leave it: the last taker sell. + bid: Option, + next_sample: i64, + period_ms: i64, + fill_ms: i64, +} + +impl Ladder { + /// The trade's ladder, `None` without a step. + /// + /// Args: + /// long: The trade's side. + /// fill_price: The buy every level counts from. + /// fill_ms: The buy's moment: nothing is read up to it. + /// placed_ms: The sell's placement, every step's time counts from. + /// armed_ms: The end of `StopLossDelay`: nothing is read before it. + /// first_ms: The first print the walk feeds; the ticker's clock is run back to it, as the + /// book stop's is, so both read the same arrivals. + /// period_ms: The ticker's period. + /// steps: The second stop and the third, those switched on. + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + long: bool, + fill_price: f64, + fill_ms: i64, + placed_ms: i64, + armed_ms: i64, + first_ms: i64, + period_ms: i64, + steps: &[Option], + ) -> Option { + let rungs: Vec = steps + .iter() + .flatten() + .map(|step| Rung { + // Whole seconds (the site), the elapsed ones rounded (the core): "more than 60" + // holds from 60.5 s — and never inside `StopLossDelay`. + from_ms: (placed_ms + ((step.after_s.max(0.0).trunc() + 0.5) * 1000.0) as i64) + .max(armed_ms), + switch_at: level_off_buy(fill_price, step.switch_pct, long), + level: level_off_buy(fill_price, step.level_pct, long), + taken: false, + }) + .collect(); + if rungs.is_empty() { + return None; + } + let period_ms = period_ms.max(1); + let anchor = fill_ms + period_ms; + let back = (anchor - first_ms).max(0) / period_ms; + Some(Self { + long, + rungs, + bid: None, + next_sample: anchor - back * period_ms, + period_ms, + fill_ms, + }) + } + + /// The ticker's arrivals strictly before `until`: every step whose time has come and whose + /// switch price the bid is past is taken, and the level of the last one taken is where the + /// stop now stands — `None` when no step was taken by `until`. + pub(super) fn before(&mut self, until: i64) -> Option { + let mut moved = None; + while self.next_sample < until { + let at = self.next_sample; + self.next_sample += self.period_ms; + if at <= self.fill_ms { + continue; + } + let Some(bid) = self.bid else { + continue; + }; + let long = self.long; + for rung in self.rungs.iter_mut().filter(|r| !r.taken) { + let past = if long { + bid > rung.switch_at + } else { + bid < rung.switch_at + }; + if at >= rung.from_ms && past { + rung.taken = true; + moved = Some(rung.level); + } + } + } + moved + } + + /// Read a print: a taker sell stands for the bid. + pub(super) fn see(&mut self, tick: &Tick) { + if tick.side == TickSide::Sell { + self.bid = Some(f64::from(tick.price)); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder/tests.rs new file mode 100644 index 00000000..99ee7ca8 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder/tests.rs @@ -0,0 +1,195 @@ +//! The stop ladder on synthetic tapes: when a step is taken, where it moves the stop, and that it +//! stays. + +use super::*; +use crate::db::tuner::ticks::ExitKind; +use crate::db::tuner::ticks::exit::ExitParams; +use crate::db::tuner::ticks::exit::line::walk; +use crate::db::tuner::ticks::exit::tests::{deal, fill, params}; + +/// A print on the bid's side of the book — a taker sell. +fn sell(t_ms: i64, price: f64) -> Tick { + Tick { + time_ms: t_ms as f64, + price: price as f32, + qty: 1.0, + side: TickSide::Sell, + } +} + +const PERIOD: i64 = super::super::TICKER_PERIOD_MS; + +fn step(after_s: f64, switch_pct: f64, level_pct: f64) -> StopStep { + StopStep { + after_s, + switch_pct, + level_pct, + } +} + +/// The step waits for its time, then for the bid past the switch price, on the ticker's +/// arrivals: a bid past it at one second does not move a five-second step before the first +/// arrival after five seconds. +#[test] +fn a_step_waits_for_its_time_and_the_bid() { + let mut ladder = Ladder::new( + true, + 100.0, + 0, + 0, + 0, + 0, + PERIOD, + &[Some(step(5.0, 0.5, 0.4))], + ) + .expect("a step"); + ladder.see(&sell(1_000, 101.0)); + // Arrivals at 2 150 and 4 300: too early. + assert_eq!(ladder.before(5_000), None); + // The arrival at 6 450 takes it: the stop moves to 0.4 % over the buy. + let level = ladder.before(7_000).expect("taken"); + assert!((level - 100.4).abs() < 1e-9, "{level}"); + // Taken once: the bid falling back does not move it again, nor back. + ladder.see(&sell(7_500, 99.0)); + assert_eq!(ladder.before(20_000), None); +} + +/// A bid short of the switch price takes nothing, however long it waits. +#[test] +fn a_bid_short_of_the_switch_price_takes_nothing() { + let mut ladder = Ladder::new( + true, + 100.0, + 0, + 0, + 0, + 0, + PERIOD, + &[Some(step(0.0, 1.0, 0.4))], + ) + .expect("a step"); + ladder.see(&sell(100, 100.9)); + assert_eq!(ladder.before(60_000), None); +} + +/// A short's switch price and level divide the buy, and its bid must be BELOW the switch price. +#[test] +fn a_short_step_divides_the_buy() { + let mut ladder = Ladder::new( + false, + 100.0, + 0, + 0, + 0, + 0, + PERIOD, + &[Some(step(0.0, 0.5, 0.4))], + ) + .expect("a step"); + // 100 / 1.005 = 99.502: a bid of 99.6 is not past it, 99.4 is. + ladder.see(&sell(100, 99.6)); + assert_eq!(ladder.before(3_000), None); + ladder.see(&sell(3_000, 99.4)); + let level = ladder.before(5_000).expect("taken"); + assert!((level - 100.0 / 1.004).abs() < 1e-9, "{level}"); +} + +/// The third stop is its own step: taken after the second, it moves the stop again. +#[test] +fn the_third_step_moves_the_stop_again() { + let steps = [Some(step(0.0, 0.5, 0.4)), Some(step(0.0, 2.0, 1.2))]; + let mut ladder = Ladder::new(true, 100.0, 0, 0, 0, 0, PERIOD, &steps).expect("steps"); + ladder.see(&sell(100, 100.8)); + let second = ladder.before(3_000).expect("second"); + assert!((second - 100.4).abs() < 1e-9); + ladder.see(&sell(3_000, 102.5)); + let third = ladder.before(5_000).expect("third"); + assert!((third - 101.2).abs() < 1e-9); +} + +/// In the walk: after the second stop is taken, a fall that the first stop would sit through +/// fires the stop at the second's level. +#[test] +fn the_walk_stops_at_the_second_level() { + let p = ExitParams { + stop_loss_pct: -5.0, + fast_stop_loss: true, + second_stop: Some(step(1.0, 0.5, 0.4)), + ..params() + }; + let ticks = [sell(500, 100.8), sell(4_000, 100.9), sell(6_000, 100.3)]; + let w = walk(&deal(false), &ticks, fill(), 105.0, &p); + assert_eq!((w.exit.kind, w.exit.t_ms), (ExitKind::Stop, 6_000), "{w:?}"); + assert!( + w.stop_level.is_some_and(|l| (l - 100.4).abs() < 1e-9), + "{w:?}" + ); + // Without the ladder the first stop sits at 95 and nothing fires. + let plain = ExitParams { + second_stop: None, + ..p + }; + let w = walk(&deal(false), &ticks, fill(), 105.0, &plain); + assert_ne!(w.exit.kind, ExitKind::Stop, "{w:?}"); +} + +/// No stop, no ladder: the fields hang on `UseStopLoss`. +#[test] +fn no_stop_no_ladder() { + let p = ExitParams { + stop_loss_pct: 0.0, + fast_stop_loss: true, + second_stop: Some(step(0.0, 0.5, 0.4)), + ..params() + }; + let ticks = [sell(500, 100.8), sell(4_000, 100.9), sell(6_000, 100.3)]; + let w = walk(&deal(false), &ticks, fill(), 105.0, &p); + assert_ne!(w.exit.kind, ExitKind::Stop, "{w:?}"); + assert_eq!(w.stop_level, None); +} + +/// `StopLossDelay` holds the ladder: a bid past the switch price inside the delay and back under +/// it by the delay's end takes nothing (the core's answer 2, 2026-09-24). +#[test] +fn the_delay_holds_the_ladder() { + let mut ladder = Ladder::new( + true, + 100.0, + 0, + 0, + 6_000, + 0, + PERIOD, + &[Some(step(1.0, 0.5, 0.4))], + ) + .expect("a step"); + ladder.see(&sell(1_000, 101.0)); + assert_eq!(ladder.before(5_000), None, "inside the delay"); + ladder.see(&sell(5_000, 100.2)); + assert_eq!( + ladder.before(20_000), + None, + "the bid came back before the first read" + ); +} + +/// The time counts from the sell's placement, its seconds rounded: a step at 2 s off a sell placed +/// at 1 s is not taken before 3.5 s (the core's answer 3). +#[test] +fn the_time_counts_from_the_sell_rounded() { + let mut ladder = Ladder::new( + true, + 100.0, + 0, + 1_000, + 0, + 0, + PERIOD, + &[Some(step(2.0, 0.5, 0.4))], + ) + .expect("a step"); + ladder.see(&sell(100, 101.0)); + // The arrival at 2 150 is before 3 500; the one at 4 300 is after. + assert_eq!(ladder.before(3_000), None); + assert!(ladder.before(5_000).is_some()); +} diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 0e89b7ea..ea1eb240 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -435,6 +435,14 @@ impl Outcome { pub fn is_trade(&self) -> bool { self.profit_pct.is_some() } + + /// Whether the position was bought and nothing closed it inside the tape. + pub fn left_open(&self) -> bool { + self.fill.is_some() + && self + .exit + .is_some_and(|exit| exit.kind == ExitKind::OpenAtWindowEnd) + } } /// The entry-side parameters of one variant: the strategy kind's own model, or the fact. diff --git a/crates/moon-core/src/db/tuner/ticks/mshot.rs b/crates/moon-core/src/db/tuner/ticks/mshot.rs index b486aacb..559a96ce 100644 --- a/crates/moon-core/src/db/tuner/ticks/mshot.rs +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -141,14 +141,16 @@ impl EntryMethod { } } -/// How a family of modifiers reads the market-wide deltas. +/// How a family of modifiers reads the market-wide and the BTC deltas. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MarketSign { - /// With the sign the report carries — `MShotAddMarketDelta` ("аналогично", FAQ :1289). + /// With the sign the report carries — `MShotAddMarketDelta` ("аналогично", FAQ :1289) and the + /// corridor's BTC terms. #[default] Signed, /// As a magnitude — the Delta Modifiers tab's `AddMarketDelta` and `AddMarket24Delta`, "по - /// модулю, то есть всегда положительный" (FAQ :1171, :1172). + /// модулю, то есть всегда положительный" (FAQ :1171, :1172), and its BTC terms likewise (the + /// core developer via LinKvo, 2026-09-24: "рыночные и BTC берутся по модулю"). Magnitude, } @@ -184,6 +186,10 @@ pub struct Modifiers { pub market_sign: MarketSign, /// `MShotAddDistance` — per cent by which the far bound's addition exceeds the near one's. pub distance_pct: f64, + /// The ceiling on the `add_pricebug` term alone, per cent; 0 for none. The corridor family's + /// `MShotAddPriceBug` contributes at most 30 % (the core developer via LinKvo, 2026-09-24); + /// the Delta Modifiers tab's `AddPriceBug` has no ceiling of its own. + pub pricebug_cap: f64, } impl Modifiers { @@ -192,7 +198,7 @@ impl Modifiers { /// FAQ example and the live check behind the sign), the market-wide ones as the family reads /// them ([`MarketSign`]). pub fn near_addition(&self, d: &Deltas) -> f64 { - let market = |delta: f64| match self.market_sign { + let wide = |delta: f64| match self.market_sign { MarketSign::Signed => delta, MarketSign::Magnitude => delta.abs(), }; @@ -204,22 +210,30 @@ impl Modifiers { + self.add_3h * d.d3h + self.add_24h * d.d24h + self.add_mark * d.dmark - + self.add_btc_1h * d.btc1h - + self.add_btc_5m * d.btc5m - + self.add_btc_1m * d.btc1m - + self.add_market_1h * market(d.market1h) - + self.add_market_24h * market(d.market24h) + + self.add_btc_1h * wide(d.btc1h) + + self.add_btc_5m * wide(d.btc5m) + + self.add_btc_1m * wide(d.btc1m) + + self.add_market_1h * wide(d.market1h) + + self.add_market_24h * wide(d.market24h) + self.add_pump_1h * d.pump1h + self.add_dump_1h * d.dump1h - + self.add_pricebug * d.pricebug + + self.pricebug_term(d.pricebug) } - /// The addition to the FAR bound: the near one scaled by `1 + distance / 100`. - pub fn far_addition(&self, d: &Deltas) -> f64 { - self.near_addition(d) * (1.0 + self.distance_pct / 100.0) + /// The `add_pricebug` term, under its own ceiling when the family has one. + fn pricebug_term(&self, pricebug: f64) -> f64 { + let term = self.add_pricebug * pricebug; + if self.pricebug_cap > 0.0 { + term.min(self.pricebug_cap) + } else { + term + } } } +/// The ceiling `MShotPriceBug`'s term stands under in the corridor family, per cent. +pub const MSHOT_PRICEBUG_CAP_PCT: f64 = 30.0; + /// Smallest distance either bound may end up at after the modifiers, in per cent — the /// expert-mode floor of `MShotPrice`, so a pumping coin cannot push the order ABOVE the price. const BOUND_FLOOR_PCT: f64 = 0.02; @@ -260,6 +274,10 @@ pub struct MshotParams { /// same corridor either way (see the module doc). pub fast_algo: bool, pub modifiers: Modifiers, + /// `MaxModifier` — the one ceiling the Delta Modifiers tab shares with the corridor: the sum + /// of the `MShotAdd*` terms is capped by it from above when it is above zero, with its sign + /// kept (no magnitude, unlike the sell's sum) — the core developer via LinKvo, 2026-09-24. + pub max_modifier: f64, /// The model's own settings, not strategy fields: the replacement latency, the replay /// method, the windows. pub model: ModelSettings, @@ -290,6 +308,7 @@ impl Default for MshotParams { minus_satoshi: false, fast_algo: false, modifiers: Modifiers::default(), + max_modifier: 0.0, model: ModelSettings::default(), } } @@ -298,11 +317,24 @@ impl Default for MshotParams { impl MshotParams { /// The effective `(near, far)` bounds in per cent for a deal's deltas, floored and ordered. pub fn bounds_pct(&self, deltas: &Deltas) -> (f64, f64) { - let near = (self.price_min_pct + self.modifiers.near_addition(deltas)).max(BOUND_FLOOR_PCT); - let far = (self.price_pct + self.modifiers.far_addition(deltas)).max(near); + let near_add = self.addition(deltas); + let far_add = near_add * (1.0 + self.modifiers.distance_pct / 100.0); + let near = (self.price_min_pct + near_add).max(BOUND_FLOOR_PCT); + let far = (self.price_pct + far_add).max(near); (near, far) } + /// The `MShotAdd*` sum for these deltas, capped from above by `MaxModifier` when it is set — + /// the near bound's addition; the far one's is it scaled by `1 + MShotAddDistance / 100`. + fn addition(&self, deltas: &Deltas) -> f64 { + let sum = self.modifiers.near_addition(deltas); + if self.max_modifier > 0.0 { + sum.min(self.max_modifier) + } else { + sum + } + } + /// Whether the near bound sits below the far one — `MShotPriceMin < MShotPrice`, the /// corridor the fields describe: the price moves above the order between the two (FAQ: 10 % /// and 7 %, the price between +7 and +10 %). A pair at or past that is a corridor no field @@ -921,3 +953,6 @@ impl Reference { extreme.or_else(|| self.check()) } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/mshot/tests.rs b/crates/moon-core/src/db/tuner/ticks/mshot/tests.rs new file mode 100644 index 00000000..33d71a8d --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/mshot/tests.rs @@ -0,0 +1,50 @@ +//! The corridor's modifier sum (the core developer via LinKvo, 2026-09-24). + +use super::*; + +/// `MaxModifier` caps the `MShotAdd*` sum — with its sign, no magnitude — before +/// `MShotAddDistance` widens it for the far bound; `MShotAddPriceBug`'s term stands under 30 %. +#[test] +fn the_corridor_sum_stands_under_max_modifier_and_the_pricebug_cap() { + let p = MshotParams { + price_pct: 10.0, + price_min_pct: 7.0, + modifiers: Modifiers { + add_1h: 1.0, + add_pricebug: 1.0, + distance_pct: 50.0, + pricebug_cap: MSHOT_PRICEBUG_CAP_PCT, + ..Modifiers::default() + }, + max_modifier: 4.0, + ..MshotParams::default() + }; + // 6 of the hourly delta, capped at 4; the far bound's addition is 4 · 1.5. + let d = Deltas { + d1h: 6.0, + ..Deltas::default() + }; + let (near, far) = p.bounds_pct(&d); + assert!( + (near - 11.0).abs() < 1e-9 && (far - 16.0).abs() < 1e-9, + "{near} {far}" + ); + // A negative sum keeps its sign: the corridor comes nearer. + let d = Deltas { + d1h: -2.0, + ..Deltas::default() + }; + let (near, _) = p.bounds_pct(&d); + assert!((near - 5.0).abs() < 1e-9, "{near}"); + // The price bug's own term stands under 30 % whatever the ceiling of the sum. + let wide = MshotParams { + max_modifier: 0.0, + ..p + }; + let d = Deltas { + pricebug: 45.0, + ..Deltas::default() + }; + let (near, _) = wide.bounds_pct(&d); + assert!((near - 37.0).abs() < 1e-9, "{near}"); +} diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index 1fb2e91e..dbdcc9fa 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -10,8 +10,8 @@ use std::collections::HashMap; -use super::exit::{ExitParams, UnmodelledRule}; -use super::mshot::{MarketSign, Modifiers, MshotParams, UsePrice}; +use super::exit::{ExitParams, StopStep, UnmodelledRule}; +use super::mshot::{MSHOT_PRICEBUG_CAP_PCT, MarketSign, Modifiers, MshotParams, UsePrice}; use super::settings::ModelSettings; /// Which group of the grid a parameter belongs to. @@ -170,6 +170,26 @@ const GRID_STOP: &[f64] = &[ -0.1, ]; const GRID_STOP_DELAY_S: &[f64] = &[0.0, 1.0, 2.0, 4.0, 6.0, 10.0, 20.0, 30.0]; +/// `TimeToSwitch2Stop` / `TimeToSwitchStop3`, whole seconds: the live ladders switch after 0–5 s +/// (78 strategies with `UseSecondStop`, 2026-09-24); the rest reach the "stop by time" use. +const GRID_SWITCH_S: &[f64] = &[ + 0.0, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0, +]; +/// `PriceToSwitch2Stop` / `PriceToSwitchStop3`, per cent off the buy: live 0.3, 0.5 and 1.5. +const GRID_SWITCH_PCT: &[f64] = &[0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.3, 1.5, 2.0, 3.0, 5.0]; +/// `SecondStopLoss` / `StopLoss3`, per cent off the buy: a break-even step lives just over zero +/// (live 0.25, 0.4, 0.8), a stop by time below it. +const GRID_STEP_LEVEL: &[f64] = &[ + -3.0, -2.0, -1.0, -0.5, -0.2, 0.0, 0.1, 0.2, 0.25, 0.3, 0.4, 0.5, 0.8, 1.0, 1.5, 2.0, +]; +/// `TrailingPercent`, negative: live −0.1 to −4 among the 81 strategies with `UseTrailing`. +const GRID_TRAILING: &[f64] = &[ + -10.0, -5.0, -4.0, -3.0, -2.0, -1.8, -1.5, -1.0, -0.8, -0.5, -0.3, -0.2, -0.1, +]; +/// `TrailingEMA`, ticks: live 0, 2 and 4. +const GRID_TRAILING_EMA: &[f64] = &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; +/// `TakeProfit` of the trailing, per cent off the buy: live 1, 2, 2.5 and 5. +const GRID_TAKE_PROFIT: &[f64] = &[0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 5.0, 10.0]; /// Every parameter of the axis, grid order: the Entry group first, then Exit. pub const TICK_PARAMS: &[TickParam] = &[ @@ -394,8 +414,36 @@ pub const TICK_PARAMS: &[TickParam] = &[ exit_bool("SellLevelRelative", ParamSection::SellOrder), exit_num("SellLevelAllowedDrop", ParamSection::SellOrder, GRID_DROP), exit_num("SellLevelWorkTime", ParamSection::SellOrder, GRID_SL_TIME_S), - exit_num("StopLoss", ParamSection::Stops, GRID_STOP), + // The Stops section in the strategy window's order. `FastStopLoss` is read with the strategy's + // value — the trigger hangs on it — but is no knob; `UseMarketOrder` is read by nothing (the + // verdict tells a market stop by the fact's own reason, `StopLoss Market Sell`); nor are the panic sell's execution fields (`StopLossSpread`, + // `StopSpreadAdd1mDelta`, `AllowedDrop`, `AllowedDrop3`, `TrailingSpread`): where a panic + // sell fills is the book's, which the tape does not carry (the developer's call, 2026-09-24). + exit_bool("UseStopLoss", ParamSection::Stops), + TickParam { + key: "StopLossEMA", + group: ParamGroup::Exit, + section: ParamSection::Stops, + // The core averages at 3, 5 and 10 only (`exit::stops::stop_average_weight`). + kind: ParamKind::Enum(&["0", "3", "5", "10"]), + kinds: ANY, + not_kinds: &[], + }, exit_num("StopLossDelay", ParamSection::Stops, GRID_STOP_DELAY_S), + exit_num("StopLoss", ParamSection::Stops, GRID_STOP), + exit_bool("UseSecondStop", ParamSection::Stops), + exit_num("TimeToSwitch2Stop", ParamSection::Stops, GRID_SWITCH_S), + exit_num("PriceToSwitch2Stop", ParamSection::Stops, GRID_SWITCH_PCT), + exit_num("SecondStopLoss", ParamSection::Stops, GRID_STEP_LEVEL), + exit_bool("UseStopLoss3", ParamSection::Stops), + exit_num("TimeToSwitchStop3", ParamSection::Stops, GRID_SWITCH_S), + exit_num("PriceToSwitchStop3", ParamSection::Stops, GRID_SWITCH_PCT), + exit_num("StopLoss3", ParamSection::Stops, GRID_STEP_LEVEL), + exit_bool("UseTrailing", ParamSection::Stops), + exit_num("TrailingPercent", ParamSection::Stops, GRID_TRAILING), + exit_num("TrailingEMA", ParamSection::Stops, GRID_TRAILING_EMA), + exit_bool("UseTakeProfit", ParamSection::Stops), + exit_num("TakeProfit", ParamSection::Stops, GRID_TAKE_PROFIT), ]; /// A numeric field of the Exit group every kind understands. @@ -450,16 +498,9 @@ const MODEL_ONLY_KEYS: &[&str] = &[ "SellModifier", "MaxModifier", "StopLossModifier", - // The stop's switch and its trigger (see `ExitParams::fast_stop_loss`). - "UseStopLoss", + // The stop's trigger (see `ExitParams::fast_stop_loss`): read with the strategy's value, no + // knob. "FastStopLoss", - "StopLossEMA", - // The trailing stop (`exit::stops::trailing`): read as the strategy sets it, not a knob yet. - "UseTrailing", - "TrailingPercent", - "TrailingEMA", - "UseTakeProfit", - "TakeProfit", // PumpsDetection's one sell move (see `exit::pump_move::PUMP_MOVE_LAG_MS`); `PumpMovePersent` is the // core's own spelling of the field. "PumpMoveTimer", @@ -489,9 +530,6 @@ const MODEL_ONLY_KEYS: &[&str] = &[ const RULE_SWITCH_KEYS: &[&str] = &[ // No sell order at all. "AutoSell", - // The stop ladder. - "UseSecondStop", - "UseStopLoss3", // SellShot is on only with a distance to keep. "IgnoreSellShot", "SellShotDistance", @@ -595,7 +633,9 @@ pub fn mshot_params(v: &StrategyValues<'_>, model: ModelSettings) -> MshotParams add_dump_1h: 0.0, market_sign: MarketSign::Signed, distance_pct: v.num("MShotAddDistance", 0.0), + pricebug_cap: MSHOT_PRICEBUG_CAP_PCT, }, + max_modifier: v.num("MaxModifier", 0.0), model, } } @@ -635,6 +675,7 @@ pub fn exit_params(v: &StrategyValues<'_>, model: ModelSettings) -> ExitParams { add_dump_1h: v.num("AddDump1h", 0.0), market_sign: MarketSign::Magnitude, distance_pct: 0.0, + pricebug_cap: 0.0, }, price_down_timer_s: v.num("PriceDownTimer", base.price_down_timer_s), price_down_pct: v.num("PriceDownPercent", base.price_down_pct), @@ -680,6 +721,21 @@ pub fn exit_params(v: &StrategyValues<'_>, model: ModelSettings) -> ExitParams { trailing_ema: v.num("TrailingEMA", base.trailing_ema), trailing_take_profit_pct: (v.bool("UseTrailing", false) && v.bool("UseTakeProfit", false)) .then(|| v.num("TakeProfit", 0.0)), + // The ladder's fields hang on `UseStopLoss` and on their own switch (param_deps.toml). + second_stop: (v.bool("UseStopLoss", true) && v.bool("UseSecondStop", false)).then(|| { + StopStep { + after_s: v.num("TimeToSwitch2Stop", 0.0), + switch_pct: v.num("PriceToSwitch2Stop", 0.0), + level_pct: v.num("SecondStopLoss", 0.0), + } + }), + third_stop: (v.bool("UseStopLoss", true) && v.bool("UseStopLoss3", false)).then(|| { + StopStep { + after_s: v.num("TimeToSwitchStop3", 0.0), + switch_pct: v.num("PriceToSwitchStop3", 0.0), + level_pct: v.num("StopLoss3", 0.0), + } + }), unmodelled: unmodelled_rule(v), model, take_from_archive: base.take_from_archive, @@ -690,8 +746,8 @@ pub fn exit_params(v: &StrategyValues<'_>, model: ModelSettings) -> ExitParams { /// /// Read by the switch, never by its fields: the fields stay in a strategy's dump with the switch /// off (`assets/param_deps.toml`). On this machine's reports (2026-09-23) the trailing stop was -/// on for the strategies of 44 trades of 2 042 and the stop ladder for 2; the trailing stop is -/// modelled since 2026-09-24 (`exit::stops::trailing`). SellShot and SellSpread are not modelled +/// on for the strategies of 44 trades of 2 042 and the stop ladder for 2; both are modelled since +/// 2026-09-24 (`exit::stops::trailing`, `exit::stops::ladder`). SellShot and SellSpread are not modelled /// at all (the developer's call, 2026-09-24); each is on for 2 live strategies of 1 422 (24.09), /// SellShot only where a distance is set (the walk kept it off at a zero one). `AutoSell` off /// places no sell order at all (no live strategy, 24.09). The EMA exit and @@ -700,8 +756,6 @@ pub fn exit_params(v: &StrategyValues<'_>, model: ModelSettings) -> ExitParams { pub(super) fn unmodelled_rule(v: &StrategyValues<'_>) -> Option { if !v.bool("AutoSell", true) { Some(UnmodelledRule::NoAutoSell) - } else if v.bool("UseSecondStop", false) || v.bool("UseStopLoss3", false) { - Some(UnmodelledRule::StopLadder) } else if !v.bool("IgnoreSellShot", true) && v.num("SellShotDistance", 0.0) != 0.0 { Some(UnmodelledRule::SellShot) } else if !v.bool("IgnoreSellSpread", true) { diff --git a/crates/moon-core/src/db/tuner/ticks/record.rs b/crates/moon-core/src/db/tuner/ticks/record.rs index 0a540e72..e3fc1b37 100644 --- a/crates/moon-core/src/db/tuner/ticks/record.rs +++ b/crates/moon-core/src/db/tuner/ticks/record.rs @@ -17,9 +17,9 @@ //! proxy, and the verdict (which replays the proxy, never the anchor) is what says how far the //! proxy may be trusted. -use super::exit::ExitParams; use super::exit::sell_order::{archived_pre_spike_ask, archived_take}; use super::exit::stops::stop_pct; +use super::exit::{ExitParams, StopStep}; use super::verify::{ POINT_TIME_TOLERANCE_MS, Verdict, archived_stop_jump, is_stop_reason, stop_jump_level, }; @@ -36,6 +36,9 @@ pub struct StopAnchor { pub delay_s: f64, pub fast: bool, pub ema: f64, + /// The ladder the fact ran (`ExitParams::second_stop`, `third_stop`). + pub second: Option, + pub third: Option, /// When the fact's stop fired and the price it sold at; `None` when the fact did not stop. pub fired: Option<(i64, f64)>, /// Up to when the fact proves the stop quiet: its activation, or the close. @@ -74,6 +77,8 @@ impl StopAnchor { delay_s: exit.stop_loss_delay_s, fast: exit.fast_stop_loss, ema: exit.stop_loss_ema, + second: exit.second_stop, + third: exit.third_stop, fired, quiet_until_ms: fired.map_or(deal.close_ms, |(t, _)| t), } @@ -95,6 +100,8 @@ impl StopAnchor { && params.stop_loss_delay_s == self.delay_s && params.fast_stop_loss == self.fast && params.stop_loss_ema == self.ema + && params.second_stop == self.second + && params.third_stop == self.third } } diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index ad3acc4c..f39f82ea 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -16,9 +16,12 @@ //! //! The objective is the total money result over the fitted deals with at least `min_n` of them //! still trading, ties broken by the profit factor — `metrics::Tally`, the same figures the KPI -//! matrix prints. A deal the variant never fills, or never closes inside its tape, is not a -//! trade and drops out of `n`; the caller prints "by N of M" beside the column so a variant -//! that wins by trading less is visible as such. +//! matrix prints. A deal the variant never fills is not a trade and drops out of `n`; the caller +//! prints "by N of M" beside the column so a variant that wins by trading less is visible as +//! such. A point that buys a deal and does not close it inside its tape, or leaves a strategy +//! with nothing standing to close a trade, is refused outright ([`closing`], the developer, +//! 2026-09-24): dropping the deal would reward the loss it carries past the tape. A switch the +//! point turns on brings the values it needs ([`deps`]). //! //! A MoonShot variant's entry is replayed the way the caller's model settings pick //! ([`super::mshot::EntryMethod`]): the corridor model from the order's creation, or the fact's @@ -152,6 +155,9 @@ pub enum SearchMiss { /// [`SearchParams::keep_corridor`], every trade's corridor at least as far from the price as /// the trade's own. Corridor, + /// No point the search visited closed every deal it bought inside the tape with something + /// standing to close each trade — a stop, or a trailing without a take profit ([`closing`]). + Unclosed, } /// How a search went — what shows whether its restarts and passes changed anything. @@ -170,8 +176,8 @@ pub struct SearchStats { pub distinct: usize, /// Points scored over the whole run, each a replay of the training slice. pub evaluations: usize, - /// Restarts that ended on a point the corridor rules refuse — none of their moves reached - /// an allowed one. + /// Restarts that ended on a point the corridor rules or the closing rule (`closing`) refuse + /// — none of their moves reached an allowed one. pub refused: usize, } @@ -185,6 +191,9 @@ pub struct SearchResult { pub train: Tally, /// What they achieve on the deals held back, when any were. pub holdout: Option, + /// How many of the deals held back the answer bought and left open inside the tape — none + /// may be among the deals it was fitted on; the holdout is only scored, so it says them. + pub holdout_open: usize, /// The seed the restarts were derived from. pub seed: u64, /// How the run went. @@ -508,7 +517,7 @@ impl<'a> Bases<'a> { } /// Every deal's result under one point, in order — `(money, spent)`, `None` where the point -/// makes no trade of the deal. +/// makes no trade of the deal — and whether it bought the deal and left it open. /// /// Args: /// deals: The deals. @@ -518,15 +527,17 @@ fn results( deals: &[PreparedDeal], of_deal: &[usize], params: &[(EntryParams, ExitParams)], -) -> Vec> { +) -> Vec<(Option<(f64, f64)>, bool)> { deals .par_iter() .zip(of_deal.par_iter()) .map(|(d, &base)| { let (entry, exit) = ¶ms[base]; - simulate(&d.deal, &d.ticks, entry, exit, d.entry_line.as_deref()) + let outcome = simulate(&d.deal, &d.ticks, entry, exit, d.entry_line.as_deref()); + let result = outcome .profit_money(&d.deal) - .map(|money| (money, d.deal.spent)) + .map(|money| (money, d.deal.spent)); + (result, outcome.left_open()) }) .collect() } @@ -542,7 +553,7 @@ fn tally_and_spent( let results = results(deals, of_deal, params); let mut tally = Tally::default(); let mut spent = 0.0; - for (money, size) in results.into_iter().flatten() { + for (money, size) in results.into_iter().filter_map(|(result, _)| result) { tally.push(money); spent += size; } @@ -671,11 +682,6 @@ fn better_score(a: &Option, b: &Option, min_n: i64) -> bool { } } -/// The tally of a point over `deals`, in order; arguments as for [`results`]. -fn tally(deals: &[PreparedDeal], of_deal: &[usize], params: &[(EntryParams, ExitParams)]) -> Tally { - tally_and_spent(deals, of_deal, params).0 -} - /// Whether `a` beats `b` under the objective, with the sample floor. fn better(a: &Tally, b: &Tally, min_n: i64) -> bool { let a_ok = a.n >= min_n; @@ -767,26 +773,6 @@ pub fn suggest( .iter() .map(|(entry, _)| ordered(entry)) .collect(); - let evaluations = std::sync::atomic::AtomicUsize::new(0); - let evaluate = |point: &Point| -> Option { - let per_base = bases.params(params.held, params.defaults, point, params.kind, model); - // A point that inverts the corridor's two fields is never proposed, whatever the - // switch: the searched fields are gridded one by one, and nothing else ties them. Only - // a search of the Entry group can produce one — an Exit search leaves the strategy's - // own fields alone, whatever they hold. - if params.vary_entry && inverts(&start_ordered, &per_base) { - return None; - } - if guard - .as_ref() - .is_some_and(|g| !g.holds(&bases.of_deal, &per_base)) - { - return None; - } - // Counted here, past the refusals: what the stats call a scored point is a replay. - evaluations.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - Some(tally(train, train_of, &per_base)) - }; // Where each number field starts on its grid — the median of what the selected strategies // hold (the held value over all of them; the schema default for one that leaves it out), // snapped to the nearest grid step: what a pair move and a perturbed start step from while @@ -813,6 +799,42 @@ pub fn suggest( Some((f.key, nearest_step(grid, value))) }) .collect(); + // The Strategies window's dependencies: what a point switches on it gives a value. + let deps = deps::Dependents::new(&fields, &start); + let evaluations = std::sync::atomic::AtomicUsize::new(0); + // Why points were refused, for the answer's reason when none is left. + let (cornered, unclosed) = ( + std::sync::atomic::AtomicUsize::new(0), + std::sync::atomic::AtomicUsize::new(0), + ); + let evaluate = |point: &Point| -> Option { + // Every number field the point switches on stands at a value (`deps`). + let full = deps.complete(point, &bases.owns, params.held, params.defaults); + let per_base = bases.params(params.held, params.defaults, &full, params.kind, model); + // A point that inverts the corridor's two fields is never proposed, whatever the + // switch: the searched fields are gridded one by one, and nothing else ties them. Only + // a search of the Entry group can produce one — an Exit search leaves the strategy's + // own fields alone, whatever they hold. + if (params.vary_entry && inverts(&start_ordered, &per_base)) + || guard + .as_ref() + .is_some_and(|g| !g.holds(&bases.of_deal, &per_base)) + { + cornered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return None; + } + // Counted here, past the refusals: what the stats call a scored point is a replay. + evaluations.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // A trade must be closed while it lasts: something that can close it stands on every + // strategy, and none of the deals it bought is left open (`closing`). + let closed = closing::protected(&per_base) + .then(|| closing::closed_tally(train, train_of, &per_base)) + .flatten(); + if closed.is_none() { + unclosed.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + closed + }; // The fields that move in pairs: the Entry group's numbers, where a corridor's distance is // shared between the base fields and the modifiers and one field alone cannot move it. let pairs: Vec<&'static TickParam> = if params.vary_entry { @@ -869,7 +891,8 @@ pub fn suggest( // end point, not two. let mut ends: Vec> = Vec::new(); for run in &runs { - let end = bases.params(params.held, params.defaults, &run.point, params.kind, model); + let full = deps.complete(&run.point, &bases.owns, params.held, params.defaults); + let end = bases.params(params.held, params.defaults, &full, params.kind, model); if !ends.contains(&end) { ends.push(end); } @@ -897,10 +920,25 @@ pub fn suggest( refused, }; let (point, score) = (best.point, best.score); + // The answer as it was scored — every field it switched on at a value — less what is in + // effect on no strategy. + let point = deps.prune( + &deps.complete(&point, &bases.owns, params.held, params.defaults), + &bases.owns, + params.held, + params.defaults, + ); // `better_score` ranks a refused point below every other, and `better` one under the floor // below any above it: a best refused or under the floor means no point held either. let Some(train_tally) = score else { - return Err(SearchMiss::Corridor); + // The rule that refused the most points is the one to name. + let load = + |c: &std::sync::atomic::AtomicUsize| c.load(std::sync::atomic::Ordering::Relaxed); + return Err(if load(&unclosed) > load(&cornered) { + SearchMiss::Unclosed + } else { + SearchMiss::Corridor + }); }; if train_tally.n < min_n { return Err(SearchMiss::Floor); @@ -913,14 +951,19 @@ pub fn suggest( .map(|(key, value)| ((*key).to_string(), value.clone())) .collect(); values.sort(); - let holdout = (train_n < deals.len()).then(|| { + let (holdout, holdout_open) = if train_n < deals.len() { let per_base = bases.params(params.held, params.defaults, &point, params.kind, model); - tally(&deals[train_n..], &bases.of_deal[train_n..], &per_base) - }); + let (tally, open) = + closing::tally_counting_open(&deals[train_n..], &bases.of_deal[train_n..], &per_base); + (Some(tally), open) + } else { + (None, 0) + }; Ok(SearchResult { values, train: train_tally, holdout, + holdout_open, seed, stats, }) @@ -1079,5 +1122,9 @@ fn point_of(values: &[(String, String)]) -> Point { point } +mod closing; +pub use self::closing::unguarded_strategies; +mod deps; + #[cfg(test)] mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/search/closing.rs b/crates/moon-core/src/db/tuner/ticks/search/closing.rs new file mode 100644 index 00000000..87bafe32 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/closing.rs @@ -0,0 +1,93 @@ +//! What a point must keep so that every trade it buys can close while it lasts (the developer, +//! 2026-09-24): "something must work on every trade — the stop, or the trailing without a take +//! profit; a trade closes by its stop or its sell". The second and third stops live on the first +//! (`UseStopLoss`), and a trailing with a take profit stands nowhere until the take profit is +//! passed, so neither guards a trade on its own. + +use std::collections::HashMap; + +use super::{PreparedDeal, Tally, params_of, point_of, results}; +use crate::db::tuner::ticks::EntryParams; +use crate::db::tuner::ticks::exit::ExitParams; +use crate::db::tuner::ticks::settings::ModelSettings; + +/// Whether every strategy of the point keeps a guard that stands from the fill on: a stop, or a +/// trailing stop without a take profit. +pub(super) fn protected(per_base: &[(EntryParams, ExitParams)]) -> bool { + per_base.iter().all(|(_, exit)| guarded(exit)) +} + +/// How many of the strategies `owns` a variant's `values` would leave with nothing standing to +/// close a trade — the rule the search refuses a point by ([`protected`]), asked of a variant as +/// it will be written, typed or found. +/// +/// Args: +/// owns: Each strategy's own values, once per strategy. +/// defaults: The live schema's numeric defaults. +/// kind: The strategies' kind, for the entry model; the guard is the exit's either way. +/// values: The variant's changes, in strategy spelling. +/// model: The model's own settings. +pub fn unguarded_strategies<'a>( + owns: impl IntoIterator>, + defaults: &HashMap, + kind: &str, + values: &[(String, String)], + model: ModelSettings, +) -> usize { + let point = point_of(values); + let held = HashMap::new(); + owns.into_iter() + .filter(|own| { + let (_, exit) = params_of(own, &held, defaults, &point, kind, model.sanitized()); + !guarded(&exit) + }) + .count() +} + +/// One strategy's guard. +fn guarded(exit: &ExitParams) -> bool { + exit.stop_loss_pct != 0.0 + || (exit.trailing_pct != 0.0 && exit.trailing_take_profit_pct.is_none()) +} + +/// The tally of a point that closes every deal it buys inside the tape — by its stop or its +/// sell — else `None`: a point that leaves one open is not one the search may pick, since the +/// loss it would carry past the tape is on no record and dropping the deal would only reward it +/// (the developer, 2026-09-24). +pub(super) fn closed_tally( + deals: &[PreparedDeal], + of_deal: &[usize], + params: &[(EntryParams, ExitParams)], +) -> Option { + let mut tally = Tally::default(); + for (result, open) in results(deals, of_deal, params) { + if open { + return None; + } + if let Some((money, _)) = result { + tally.push(money); + } + } + Some(tally) +} + +/// The tally of a point over `deals` — the deals it closed — and how many it bought and left +/// open inside the tape, which the tally cannot hold. +pub(super) fn tally_counting_open( + deals: &[PreparedDeal], + of_deal: &[usize], + params: &[(EntryParams, ExitParams)], +) -> (Tally, usize) { + let mut tally = Tally::default(); + let mut open = 0; + for (result, left_open) in results(deals, of_deal, params) { + open += usize::from(left_open); + if let Some((money, _)) = result { + tally.push(money); + } + } + (tally, open) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs new file mode 100644 index 00000000..24707459 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs @@ -0,0 +1,92 @@ +//! The guard every point must keep. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use super::super::tests::{prepared, tick}; +use super::super::{DEFAULT_MAX_PASSES, PreparedDeal, SearchParams, suggest}; +use super::*; +use crate::db::tuner::threshold_search::SearchHandle; +use crate::db::tuner::ticks::{ModelSettings, TICK_PARAMS}; + +fn exit(stop: f64, trailing: f64, take_profit: Option) -> (EntryParams, ExitParams) { + ( + EntryParams::Fact, + ExitParams { + stop_loss_pct: stop, + trailing_pct: trailing, + trailing_take_profit_pct: take_profit, + ..ExitParams::default() + }, + ) +} + +#[test] +fn a_stop_or_a_bare_trailing_guards_a_trade() { + assert!(protected(&[exit(-2.0, 0.0, None)])); + assert!(protected(&[exit(0.0, -1.0, None)])); + // A trailing with a take profit stands nowhere until the take profit is passed. + assert!(!protected(&[exit(0.0, -1.0, Some(2.0))])); + assert!(!protected(&[exit(0.0, 0.0, None)])); + // Every strategy of the point, not most of them. + assert!(!protected(&[exit(-2.0, 0.0, None), exit(0.0, 0.0, None)])); +} + +/// Turning the stop off leaves the falling deals open past the tape — a loss on no record — and +/// such a point is refused rather than scored on the deals it did close (the developer, +/// 2026-09-24): the search keeps the stop. +#[test] +fn a_point_that_leaves_a_deal_open_is_refused() { + let deals: Vec = (1..=6) + .map(|uid| { + let mut d = prepared(uid, 101.0); + let t0 = d.deal.buy_ms; + d.ticks = Arc::from(vec![ + tick(t0 - 500, 100.0), + tick(t0, 100.0), + tick(t0 + 300, 97.0), + ]); + d.own = Arc::new( + [ + ("SellPrice", "2"), + ("StopLoss", "-2"), + ("FastStopLoss", "YES"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ); + d + }) + .collect(); + let (held, defaults) = (HashMap::new(), HashMap::new()); + let locked: HashSet = TICK_PARAMS + .iter() + .map(|f| f.key.to_string()) + .filter(|k| k != "UseStopLoss") + .collect(); + let params = SearchParams { + held: &held, + defaults: &defaults, + kind: "PumpsDetection", + vary_entry: false, + vary_exit: true, + locked: &locked, + restarts: 2, + min_n: Some(1), + seed: Some(3), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, + model: ModelSettings::default(), + }; + let result = suggest(&deals, ¶ms, &SearchHandle::new()).expect("the stop is a point"); + assert!( + !result + .values + .iter() + .any(|(k, v)| k == "UseStopLoss" && v == "NO"), + "{result:?}" + ); + assert_eq!(result.train.n, 6, "every deal closed, by its stop"); +} diff --git a/crates/moon-core/src/db/tuner/ticks/search/deps.rs b/crates/moon-core/src/db/tuner/ticks/search/deps.rs new file mode 100644 index 00000000..b84e3d1b --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/deps.rs @@ -0,0 +1,139 @@ +//! The Strategies window's field dependencies inside the search (`assets/param_deps.toml`): a +//! field is in effect only while its rule holds — `TakeProfit` under `UseTrailing=YES` and +//! `UseTakeProfit=YES`, the ladder's fields under `UseStopLoss=YES` and their own switch. +//! +//! Two rules keep a point honest about them: +//! +//! - a number field the point puts in effect on a strategy that holds no value for it is set on +//! the point — the step the search starts it from, the median of the values the strategies +//! hold — so a switch is never turned on with nothing behind it (a `UseTakeProfit = YES` whose +//! per cent is on no record: the developer, 2026-09-24). Like every value of a variant it is +//! one value for every strategy of the point — Save writes it to each — so a strategy that held +//! its own is scored at it too, and the point is scored as it will be written; +//! - a field in effect on no strategy is left out of the answer: it moves nothing. + +use std::collections::HashMap; + +use super::{Point, spell}; +use crate::db::tuner::ticks::params::{ParamKind, TickParam}; +use crate::feed::strategy_deps::{FieldDeps, Values}; + +/// The value a condition field reads when a strategy's dump leaves it out and no live schema +/// says otherwise — the model's own fallbacks (`params::exit_params`, `ExitParams::default`), so +/// "in effect" is the same question the model answers when it reads the field. +const CONDITION_FALLBACKS: &[(&str, &str)] = &[ + ("hodlmode", "NO"), + ("autosell", "YES"), + ("usestoploss", "YES"), + ("usesecondstop", "NO"), + ("usestoploss3", "NO"), + ("usetrailing", "NO"), + ("usetakeprofit", "NO"), + ("pricedowntimer", "0"), + ("sellleveldelay", "0"), + ("sellleveltime", "0"), +]; + +/// The varied number fields and the step each is completed at. +pub(super) struct Dependents { + rules: FieldDeps, + numbers: Vec<(&'static TickParam, String)>, +} + +impl Dependents { + /// Args: + /// fields: The fields the search varies. + /// start: Where each number field starts on its grid. + pub(super) fn new(fields: &[&'static TickParam], start: &HashMap<&'static str, usize>) -> Self { + let numbers = fields + .iter() + .filter(|f| matches!(f.kind, ParamKind::Num { .. })) + .filter_map(|f| Some((*f, spell(&f.kind, *start.get(f.key)?)))) + .collect(); + Self { + rules: FieldDeps::bundled(), + numbers, + } + } + + /// `point` with every varied number field it puts in effect on a strategy that holds no value + /// for it set at its start step — for every strategy, as a variant's values are. + pub(super) fn complete( + &self, + point: &Point, + owns: &[&HashMap], + held: &HashMap, + defaults: &HashMap, + ) -> Point { + let mut out = point.clone(); + // A completed number can be another's condition (`PriceDownTimer<>0`) on any strategy: + // every strategy is read again until nothing more comes into effect. + loop { + let before = out.len(); + for own in owns { + let values = effective(own, held, &out, defaults); + for (field, at_start) in &self.numbers { + let missing = !out.contains_key(field.key) + && !held.contains_key(field.key) + && !own.contains_key(field.key); + if missing && self.rules.field_active(field.key, &values) { + out.insert(field.key, at_start.clone()); + } + } + } + if out.len() == before { + break; + } + } + out + } + + /// `point` without the fields in effect on none of the strategies. + pub(super) fn prune( + &self, + point: &Point, + owns: &[&HashMap], + held: &HashMap, + defaults: &HashMap, + ) -> Point { + let values: Vec = owns + .iter() + .map(|own| effective(own, held, point, defaults)) + .collect(); + point + .iter() + .filter(|(key, _)| values.iter().any(|v| self.rules.field_active(key, v))) + .map(|(key, value)| (*key, value.clone())) + .collect() + } +} + +/// One strategy's values as the rules read them: its own, `held` over them, the point over that, +/// lowercase; a condition field none of them spells reads the live schema's default, else the +/// model's fallback. +fn effective( + own: &HashMap, + held: &HashMap, + point: &Point, + defaults: &HashMap, +) -> Values { + let mut values: Values = own + .iter() + .chain(held.iter()) + .map(|(k, v)| (k.to_ascii_lowercase(), v.clone())) + .collect(); + for (key, value) in point { + values.insert(key.to_ascii_lowercase(), value.clone()); + } + for (key, fallback) in CONDITION_FALLBACKS { + values.entry((*key).to_string()).or_insert_with(|| { + defaults + .get(*key) + .map_or_else(|| (*fallback).to_string(), f64::to_string) + }); + } + values +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs new file mode 100644 index 00000000..a01e5c3d --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs @@ -0,0 +1,113 @@ +//! The search's field dependencies on hand-made strategies. + +use super::*; +use crate::db::tuner::ticks::TICK_PARAMS; + +fn field(key: &str) -> &'static TickParam { + TICK_PARAMS.iter().find(|f| f.key == key).expect("a knob") +} + +fn own(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +fn point(pairs: &[(&'static str, &str)]) -> Point { + pairs.iter().map(|(k, v)| (*k, (*v).to_string())).collect() +} + +/// The dependents of the trailing's take profit, started at the first step of their grids. +fn deps() -> Dependents { + let fields = [ + field("UseTrailing"), + field("TrailingPercent"), + field("UseTakeProfit"), + field("TakeProfit"), + ]; + let start: HashMap<&'static str, usize> = [("TrailingPercent", 0), ("TakeProfit", 2)] + .into_iter() + .collect(); + Dependents::new(&fields, &start) +} + +/// A switch turned on with no value behind it gets one: the take profit's per cent at its start. +#[test] +fn a_switch_turned_on_brings_its_values() { + let bare = own(&[]); + let p = point(&[("UseTrailing", "YES"), ("UseTakeProfit", "YES")]); + let out = deps().complete(&p, &[&bare], &HashMap::new(), &HashMap::new()); + assert_eq!(out.get("TakeProfit").map(String::as_str), Some("1")); + assert_eq!(out.get("TrailingPercent").map(String::as_str), Some("-10")); +} + +/// A strategy that holds the value keeps its own: nothing is laid over it. +#[test] +fn a_value_on_record_is_not_replaced() { + let set = own(&[("TakeProfit", "5.0"), ("TrailingPercent", "-4.0")]); + let p = point(&[("UseTrailing", "YES"), ("UseTakeProfit", "YES")]); + let out = deps().complete(&p, &[&set], &HashMap::new(), &HashMap::new()); + assert!(!out.contains_key("TakeProfit") && !out.contains_key("TrailingPercent")); +} + +/// A switch left out of the dump reads the model's fallback: `UseTakeProfit` absent is off, so +/// its per cent is not brought in. +#[test] +fn an_absent_switch_reads_the_models_fallback() { + let bare = own(&[]); + let p = point(&[("UseTrailing", "YES")]); + let out = deps().complete(&p, &[&bare], &HashMap::new(), &HashMap::new()); + assert!(!out.contains_key("TakeProfit"), "{out:?}"); + assert!(out.contains_key("TrailingPercent")); +} + +/// A field in effect on no strategy moves nothing and is left out of the answer. +#[test] +fn a_field_in_effect_nowhere_is_pruned() { + let bare = own(&[]); + let p = point(&[ + ("UseTrailing", "NO"), + ("TakeProfit", "2"), + ("TrailingPercent", "-2"), + ]); + let out = deps().prune(&p, &[&bare], &HashMap::new(), &HashMap::new()); + assert_eq!(out.len(), 1, "{out:?}"); + assert!(out.contains_key("UseTrailing")); + // In effect on ONE strategy is enough to stay. + let on = own(&[("UseTrailing", "YES"), ("UseTakeProfit", "YES")]); + let p = point(&[("TakeProfit", "2")]); + let out = deps().prune(&p, &[&bare, &on], &HashMap::new(), &HashMap::new()); + assert!(out.contains_key("TakeProfit")); +} + +/// Two strategies, one holding the per cent and one not: the point carries it — one value for +/// both, as Save writes it — at the start step. +#[test] +fn a_completed_value_is_the_points_for_every_strategy() { + let set = own(&[("TakeProfit", "5.0")]); + let bare = own(&[]); + let p = point(&[("UseTrailing", "YES"), ("UseTakeProfit", "YES")]); + let out = deps().complete(&p, &[&set, &bare], &HashMap::new(), &HashMap::new()); + assert_eq!(out.get("TakeProfit").map(String::as_str), Some("1")); +} + +/// A number one strategy lacks can put another's dependent in effect: the first strategy keeps +/// PriceDown off (`PriceDownTimer = 0`) and holds no per cent, the second holds the per cent and no +/// timer. Completing the second's timer switches PriceDown on for the first as well, and its per +/// cent is then completed too — whatever order the strategies come in. +#[test] +fn a_completion_reaches_every_strategy_whatever_the_order() { + let fields = [field("PriceDownTimer"), field("PriceDownPercent")]; + let start: HashMap<&'static str, usize> = [("PriceDownTimer", 3), ("PriceDownPercent", 4)] + .into_iter() + .collect(); + let deps = Dependents::new(&fields, &start); + let off = own(&[("PriceDownTimer", "0")]); + let pct = own(&[("PriceDownPercent", "10")]); + for owns in [[&off, &pct], [&pct, &off]] { + let out = deps.complete(&point(&[]), &owns, &HashMap::new(), &HashMap::new()); + assert!(out.contains_key("PriceDownTimer"), "{out:?}"); + assert!(out.contains_key("PriceDownPercent"), "{out:?}"); + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index c6959a0a..c707cf3f 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -7,7 +7,7 @@ use super::*; use crate::db::tuner::ticks::Deltas; use crate::feed::types::Side; -fn tick(t_ms: i64, price: f64) -> Tick { +pub(super) fn tick(t_ms: i64, price: f64) -> Tick { Tick { time_ms: t_ms as f64, price: price as f32, @@ -18,7 +18,7 @@ fn tick(t_ms: i64, price: f64) -> Tick { /// A PumpsDetection deal (entry from the fact, take by `SellPrice`) bought at 100 whose tape peaks at `peak` after the /// fill, then falls back to the fact's exit. -fn prepared(uid: i64, peak: f64) -> PreparedDeal { +pub(super) fn prepared(uid: i64, peak: f64) -> PreparedDeal { let deal = Deal { report_uid: uid, core_uid: 1, @@ -71,7 +71,7 @@ fn prepared(uid: i64, peak: f64) -> PreparedDeal { fn with_take(uid: i64, take: &str) -> PreparedDeal { let mut deal = prepared(uid, 101.0); deal.own = Arc::new( - [("SellPrice", take), ("StopLoss", "0")] + [("SellPrice", take), ("StopLoss", "-50")] .into_iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), @@ -88,7 +88,7 @@ fn tape_end_ms(d: &PreparedDeal) -> i64 { } fn base() -> HashMap { - [("SellPrice", "0.2"), ("StopLoss", "0")] + [("SellPrice", "0.2"), ("StopLoss", "-50")] .into_iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect() diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index f0697750..005c3439 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -1610,21 +1610,22 @@ fn a_moonshot_lifted_to_the_ask_needs_the_recorded_ask() { assert!(ExitModel::new(&ExitParams::default()).take_known(&deal())); } -/// A modifier deep enough to drive the distance negative must not put the take on the losing -/// side of the entry — the line steps DOWN from the take, and a take below the fill inverts it. +/// The sum's sign does not reach the sell — the core takes its magnitude — and a negative +/// COEFFICIENT moves the sell after the `SellPrice` floor, so it can take it under that floor +/// (the core developer via LinKvo, 2026-09-24). #[test] -fn a_negative_modifier_cannot_push_the_take_through_the_fill() { +fn a_negative_coefficient_takes_the_sell_under_its_floor() { let mut mods = Modifiers::default(); mods.add_1h = 1.0; let params = ExitParams { sell_price_pct: 1.0, - sell_modifier: 1.0, + sell_modifier: -0.5, sell_mods: mods, ..ExitParams::default() }; - let d = Deal { + let deal_at = |d1h: f64| Deal { deltas: Deltas { - d1h: -50.0, + d1h, ..Deltas::default() }, ..deal() @@ -1633,11 +1634,12 @@ fn a_negative_modifier_cannot_push_the_take_through_the_fill() { t_ms: 10_000, price: 100.0, }; - let take = ExitModel::new(¶ms).take_level(&d, &[], fill); - assert!( - (take - 100.0).abs() < 1e-9, - "floored at the fill, got {take}" - ); + // Σ = |±1| = 1, times −0.5 = −0.5 % off the placed 101: 100.495, under the floor of 101 and + // still over the buy. + for d1h in [1.0, -1.0] { + let take = ExitModel::new(¶ms).take_level(&deal_at(d1h), &[], fill); + assert!((take - 100.495).abs() < 1e-9, "Σ {d1h}: {take}"); + } } /// The grid must not offer a knob that moves nothing: `SellPrice` is not a MoonHook's take. @@ -1801,8 +1803,8 @@ fn an_adjustment_through_the_entry_leaves_no_stop() { 0.0, "no stop, not a near one" ); - // A negative delta sum with a positive coefficient reaches the same place from the other - // side. + // A falling coin does not: the sum is a magnitude, so a positive coefficient deepens the + // stop whichever way the deltas went — −2 − |−70|·0.3 = −23. let other = ExitParams { stop_loss_modifier: 0.3, ..base.clone() @@ -1814,7 +1816,7 @@ fn an_adjustment_through_the_entry_leaves_no_stop() { }, ..deal() }; - assert_eq!(moon_core_stop_pct(&other, &down, down.buy_ms), 0.0); + assert!((moon_core_stop_pct(&other, &down, down.buy_ms) - -23.0).abs() < 1e-9); // A modifier that only moves the stop within its own side is applied as it is. let mild = Deal { deltas: Deltas { @@ -1849,15 +1851,15 @@ fn a_cancelled_stop_does_not_fire_at_the_entry() { mods.add_1h = 1.0; let params = ExitParams { stop_loss_pct: -2.0, - stop_loss_modifier: 0.2, + stop_loss_modifier: -0.2, sell_price_pct: 5.0, sell_mods: mods, ..ExitParams::default() }; - // Σ = −10, so −2 − (−10·0.2) = 0 exactly without the clamp. + // Σ = 10, so −2 − (10·(−0.2)) = 0 exactly without the clamp. let d = Deal { deltas: Deltas { - d1h: -10.0, + d1h: 10.0, ..Deltas::default() }, ..deal() @@ -1939,16 +1941,16 @@ fn sell_modifiers_lift_the_take_by_the_faq_example() { t_ms: 10_000, price: 100.0, }; - // 1 % of SellPrice plus 5 % * 0.2 = 2 % in all. + // The 101 of SellPrice, then 5 % * 0.2 = 1 % higher. let take = ExitModel::new(¶ms).take_level(&d, &[], fill); - assert!((take - 102.0).abs() < 1e-9, "{take}"); + assert!((take - 102.01).abs() < 1e-9, "{take}"); // `MaxModifier` caps the SUM before the coefficient: min(2, 5) * 0.2 = 0.4. let capped = ExitParams { max_modifier: 2.0, ..params.clone() }; let take = ExitModel::new(&capped).take_level(&d, &[], fill); - assert!((take - 101.4).abs() < 1e-9, "{take}"); + assert!((take - 101.404).abs() < 1e-9, "{take}"); // No coefficient, no movement — whatever the deltas. let off = ExitParams { sell_modifier: 0.0, diff --git a/crates/moon-core/src/db/tuner/ticks/unmodelled.rs b/crates/moon-core/src/db/tuner/ticks/unmodelled.rs index 2a118916..56436b37 100644 --- a/crates/moon-core/src/db/tuner/ticks/unmodelled.rs +++ b/crates/moon-core/src/db/tuner/ticks/unmodelled.rs @@ -9,6 +9,11 @@ //! - the panic sell's execution — `AllowedDrop`, `AllowedDrop3`, `StopLossSpread`, //! `StopSpreadAdd1mDelta`, `TrailingSpread`: the verdict judges a stop by its decision, and a //! variant that keeps the trade's stop takes the fact's own sale (`record::StopAnchor`); +//! - the liquidation guards and the grid's fixed stop — `DontSellBelowLiq`, `StopAboveLiq`, +//! `StopLossFixed`: not taken into account at all (the developer's call, 2026-09-24); +//! - `UseMarketOrder` — how a stop's sale goes, the book's again: the verdict tells a market stop +//! by the fact's own reason (`StopLoss Market Sell`), and a variant keeping the stop takes the +//! fact's sale; //! - the entry — `SellEMACheckEnter` (checks the EMA filter before the BUY), `BuyModifier`, //! `DetectModifier`; //! - the EMA sell — `SellByCustomEMA` and its `SellEMADelay`: unused, and left out of the list @@ -67,24 +72,12 @@ const fn watched(key: &'static str, section: ParamSection, default: &'static str /// Every exit field a strategy can switch on that the model does not have, in the strategy /// window's order. const WATCHED: &[Watched] = &[ - // Stops. - Watched { - rule: Some(UnmodelledRule::StopLadder), - ..watched("UseSecondStop", ParamSection::Stops, "NO") - }, - Watched { - rule: Some(UnmodelledRule::StopLadder), - ..watched("UseStopLoss3", ParamSection::Stops, "NO") - }, + // Stops. The ladder is modelled (`exit::stops::ladder`); `DontSellBelowLiq`, `StopAboveLiq` + // and `StopLossFixed` are left out on purpose (the developer's call, 2026-09-24). // A stop on the ratio of buy to sell volume, over trades the tape has but a rule it does not. watched("UseBV_SV_Stop", ParamSection::Stops, "NO"), // Stops taken from a Telegram signal. watched("UseSignalStops", ParamSection::Stops, "NO"), - // Keeps the stop's sale and PriceDown off the liquidation price. - watched("DontSellBelowLiq", ParamSection::Stops, "NO"), - watched("StopAboveLiq", ParamSection::Stops, "0"), - // The stop of a grid position stays at the first order's. - watched("StopLossFixed", ParamSection::Stops, "NO"), // A panic sell on a delisting message. watched("PanicSellDelisted", ParamSection::Stops, "NO"), // Sell order. diff --git a/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs b/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs index 59945323..ca8330d8 100644 --- a/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs @@ -29,60 +29,78 @@ fn a_plain_strategy_raises_nothing() { #[test] fn switched_on_fields_come_with_value_and_section() { let v = values(&[ - ("UseSecondStop", "YES"), - ("DontSellBelowLiq", "True"), - ("StopAboveLiq", "50"), + ("UseBV_SV_Stop", "YES"), ("PanicSellDelisted", "YES"), + ("SellByFilters", "30"), ("IgnoreSellSpread", "NO"), ]); let found = unmodelled_fields(&v, &HashMap::new(), &FieldDeps::bundled()); assert_eq!( keys(&found), [ - "UseSecondStop", - "DontSellBelowLiq", - "StopAboveLiq", + "UseBV_SV_Stop", "PanicSellDelisted", + "SellByFilters", "IgnoreSellSpread" ] ); - assert_eq!(found[2].value, "50"); - assert_eq!(found[2].section, ParamSection::Stops); - assert_eq!(found[3].section, ParamSection::Stops); - assert_eq!(found[0].rule, Some(UnmodelledRule::StopLadder)); - assert_eq!(found[4].rule, Some(UnmodelledRule::SellSpread)); - assert_eq!(found[1].rule, None, "a warning, not a rule"); + assert_eq!(found[2].value, "30"); + assert_eq!(found[2].section, ParamSection::SellOrder); + assert_eq!(found[1].section, ParamSection::Stops); + assert_eq!(found[3].rule, Some(UnmodelledRule::SellSpread)); + assert_eq!(found[0].rule, None, "a warning, not a rule"); +} + +/// The stop ladder is modelled, and the liquidation guards and the grid's fixed stop are left out +/// on purpose (the developer's call, 2026-09-24): none of them raises the warning. +#[test] +fn the_ladder_and_the_left_out_stop_fields_raise_nothing() { + let v = values(&[ + ("UseSecondStop", "YES"), + ("UseStopLoss3", "YES"), + ("DontSellBelowLiq", "True"), + ("StopAboveLiq", "50"), + ("StopLossFixed", "YES"), + ]); + assert!(unmodelled_fields(&v, &HashMap::new(), &FieldDeps::bundled()).is_empty()); +} + +/// The live schema's default wins over the one written here: a value AT it raises nothing. +#[test] +fn the_schema_default_decides_what_is_changed() { + let v = values(&[("SellByFilters", "50")]); + let defaults: HashMap = [("sellbyfilters".to_string(), 50.0)].into(); + assert!(unmodelled_fields(&v, &defaults, &FieldDeps::bundled()).is_empty()); + // A schema that says the sell is off by default fills `AutoSell` for the rule. + let v = values(&[("SellByFilters", "30")]); + let off: HashMap = [("autosell".to_string(), 0.0)].into(); + assert!(unmodelled_fields(&v, &off, &FieldDeps::bundled()).is_empty()); } /// A field whose dependency rule does not hold is not in effect, as the Strategies window greys -/// it out: the stop's options mean nothing with `UseStopLoss` off. +/// it out: `SellByFilters` means nothing with `AutoSell` off. #[test] fn a_field_under_a_switch_that_is_off_is_not_in_effect() { - let v = values(&[("UseStopLoss", "NO"), ("DontSellBelowLiq", "YES")]); - assert!(unmodelled_fields(&v, &HashMap::new(), &FieldDeps::bundled()).is_empty()); - let on = values(&[("UseStopLoss", "YES"), ("DontSellBelowLiq", "YES")]); + let v = values(&[("AutoSell", "NO"), ("SellByFilters", "30")]); + assert!( + !keys(&unmodelled_fields( + &v, + &HashMap::new(), + &FieldDeps::bundled() + )) + .contains(&"SellByFilters") + ); + let on = values(&[("AutoSell", "YES"), ("SellByFilters", "30")]); assert_eq!( keys(&unmodelled_fields( &on, &HashMap::new(), &FieldDeps::bundled() )), - ["DontSellBelowLiq"] + ["SellByFilters"] ); } -/// The live schema's default wins over the one written here: a value AT it raises nothing. -#[test] -fn the_schema_default_decides_what_is_changed() { - let v = values(&[("StopAboveLiq", "50")]); - let defaults: HashMap = [("stopaboveliq".to_string(), 50.0)].into(); - assert!(unmodelled_fields(&v, &defaults, &FieldDeps::bundled()).is_empty()); - // A schema that says the stop is off by default fills `UseStopLoss` for the rule. - let v = values(&[("DontSellBelowLiq", "YES")]); - let off: HashMap = [("usestoploss".to_string(), 0.0)].into(); - assert!(unmodelled_fields(&v, &off, &FieldDeps::bundled()).is_empty()); -} - /// `UseScalpingMode` acts only under a 1 % `SellPrice`; SellShot only with a distance. #[test] fn the_extra_conditions_hold_the_field_back() { @@ -117,8 +135,6 @@ fn auto_sell_off_is_raised() { fn every_unmodelled_rule_is_raised() { let cases = [ values(&[("AutoSell", "NO")]), - values(&[("UseSecondStop", "YES")]), - values(&[("UseStopLoss3", "YES"), ("UseStopLoss", "YES")]), values(&[("IgnoreSellShot", "NO"), ("SellShotDistance", "0.1")]), values(&[("IgnoreSellSpread", "NO")]), ]; diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 4b7f344b..6a10f03f 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -255,7 +255,14 @@ pub fn verify( // No line stood at the close: a miss of the exit group, not an unanswered question. (Some(false), None, None) } else if closed.kind == ExitKind::Stop && exit_rule_matches(closed.kind, &deal.sell_reason) { - verify_stop(deal, &fact_exit, &walked.points, closed, exit_points) + verify_stop( + deal, + &fact_exit, + &walked.points, + closed, + walked.stop_level, + exit_points, + ) } else if exit_rule_matches(closed.kind, &deal.sell_reason) { let dev = deviation_pct(closed.price, deal.sell_price); let tolerance = model.price_pct; @@ -494,19 +501,24 @@ fn is_fill_point(deal: &Deal, exit: &ExitParams, last: (i64, f64), prev: (i64, f /// exit: The parameters the fact is replayed with. /// modelled: Every level the modelled line stood at. /// closed: The modelled stop. +/// stop_level: Where the walk's stop stood when it fired — a ladder step's level once one was +/// taken; `None` falls back to the first stop's level. /// exit_points: The archived Exit line, when the archive holds it. fn verify_stop( deal: &Deal, exit: &ExitParams, modelled: &[LinePoint], closed: Exit, + stop_level: Option, exit_points: Option<&[(i64, f64)]>, ) -> (Option, Option, Option<(usize, usize)>) { - let level = level_off_buy( - deal.buy_price, - stop_pct(exit, deal, deal.buy_ms), - deal.is_long(), - ); + let level = stop_level.unwrap_or_else(|| { + level_off_buy( + deal.buy_price, + stop_pct(exit, deal, deal.buy_ms), + deal.is_long(), + ) + }); let stated = stated_stop_level(&deal.sell_reason); let panic_at = stop_jump_level(deal, exit); let mut activation: Option = None; diff --git a/crates/moon-core/tests/diagnostics_contract.rs b/crates/moon-core/tests/diagnostics_contract.rs index 7ce2640c..7b57fbdd 100644 --- a/crates/moon-core/tests/diagnostics_contract.rs +++ b/crates/moon-core/tests/diagnostics_contract.rs @@ -45,6 +45,10 @@ const ENV_ALLOW: &[(&str, &str)] = &[ "MOON_TICKS_PROBE", "names the route, market and slice an #[ignore] test walks against the live venue by hand; a switch in a user's file must not send requests", ), + ( + "MOON_TUNER_SEARCH_PROBE", + "PRESSES the Entry/Exit axis's Search on its own at startup; a switch in a user's file must not start a search", + ), ( "MOON_CRASH_PROBE", "raises a real access violation to exercise the crash report; a switch that kills the process must never be a key in a file a user edits", diff --git a/crates/moon-ui-gpui/src/analytics/bg.rs b/crates/moon-ui-gpui/src/analytics/bg.rs index fb0dc25c..8774b3ee 100644 --- a/crates/moon-ui-gpui/src/analytics/bg.rs +++ b/crates/moon-ui-gpui/src/analytics/bg.rs @@ -127,24 +127,33 @@ impl Drop for LatestReads { } } +/// Every lane a shared scope change retires, the Entry/Exit search aside. +const SCOPE_LANES: [ReadLane; 12] = [ + ReadLane::Summary, + ReadLane::StrategyBase, + ReadLane::Calendar, + ReadLane::FilterKpi, + ReadLane::FilterHistogram, + ReadLane::Coins, + ReadLane::CoinKpi, + ReadLane::CoinPicked, + ReadLane::Time, + ReadLane::Ticks, + ReadLane::TicksReplay, + ReadLane::TicksVariants, +]; + impl AnalyticsView { /// Cancel every replaceable read affected by a shared Analytics scope change. pub(super) fn cancel_latest_reads(&mut self) { - self.latest_reads.cancel(&[ - ReadLane::Summary, - ReadLane::StrategyBase, - ReadLane::Calendar, - ReadLane::FilterKpi, - ReadLane::FilterHistogram, - ReadLane::Coins, - ReadLane::CoinKpi, - ReadLane::CoinPicked, - ReadLane::Time, - ReadLane::Ticks, - ReadLane::TicksReplay, - ReadLane::TicksVariants, - ReadLane::TicksSearch, - ]); + self.latest_reads.cancel(&SCOPE_LANES); + self.latest_reads.cancel(&[ReadLane::TicksSearch]); + } + + /// Cancel every read a report-axis move retires — all of [`Self::cancel_latest_reads`]' + /// but the Entry/Exit search, which outlives the move (`TicksState::invalidate_for_axis`). + pub(super) fn cancel_reads_for_axis_move(&mut self) { + self.latest_reads.cancel(&SCOPE_LANES); } /// Cancel replaceable Strategy-axis reads when its selection scope changes. diff --git a/crates/moon-ui-gpui/src/analytics/mod.rs b/crates/moon-ui-gpui/src/analytics/mod.rs index 35e25ba7..1938fd3d 100644 --- a/crates/moon-ui-gpui/src/analytics/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/mod.rs @@ -1153,7 +1153,7 @@ impl AnalyticsView { /// every period bound moves. But the SCOPE (period, filters) does not, so this is a /// writer-driven catch-up, not a user reload: the visible snapshot stays on screen, with no /// blocking overlay, until the replacement lands. The observer retires EVERY in-flight read - /// identity for the old axis — `seq`, `cal_seq`, `cancel_latest_reads`, plus `time_tuner`, + /// identity for the old axis — `seq`, `cal_seq`, `cancel_reads_for_axis_move`, plus `time_tuner`, /// `coins` and `coin_lists` `invalidate()` for the axes that keep their own request /// generations — because a cancelled read is not silently dropped: the DB layer raises a /// real SQLite interrupt that gets classified as a durable `Settled` failure, so a read @@ -1169,8 +1169,10 @@ impl AnalyticsView { /// is captioned as fitted across the move. A minutes-long composition is the most expensive /// thing this window does, and a report generation advance — a strictly larger change — /// already does not retire it (`TunerState::mark_report_stale`). - /// `TunerState::invalidate_for_axis` is that path. With no joint run live the tuner is - /// invalidated exactly as before: drafts cleared, every identity retired. + /// `TunerState::invalidate_for_axis` is that path. The Entry/Exit search is the other: its lane + /// is left out of the cancel and `TicksState::invalidate_for_axis` keeps it running. With no + /// joint run live the tuner is invalidated exactly as before: drafts cleared, every identity + /// retired. /// /// Args: /// cx: Analytics window context used to schedule a catch-up only when the axis moved. @@ -1186,11 +1188,11 @@ impl AnalyticsView { self.axis = axis; self.seq = self.seq.wrapping_add(1); self.cal_seq = self.cal_seq.wrapping_add(1); - self.cancel_latest_reads(); + self.cancel_reads_for_axis_move(); self.tuner.invalidate_for_axis(); self.time_tuner.invalidate(); self.coins.invalidate(); - self.ticks.invalidate(); + self.ticks.invalidate_for_axis(); self.coin_lists.invalidate(); self.mark_report_data_stale(); self.request_report_refresh(RefreshUrgency::Writer, false, cx); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs index 3ca47554..cfd17458 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/mod.rs @@ -76,6 +76,7 @@ pub(super) use strat_columns::{STRAT_COLS_ALL, STRAT_COLS_DEFAULT, STRAT_COLS_DE use super::AnalyticsView; use super::refresh::RefreshUrgency; use crate::design; +use crate::design::moon; use moon_core::config::layout::StratColsByMode; /// Parse a strategy-list row key `"strategyid@core_uid"` (the list is split PER CORE) @@ -409,11 +410,34 @@ impl AnalyticsView { if self.sel_strategy.is_some() { return false; } + // The search probe (`ticks/variants/probe.rs`) observes the Entry/Exit axis. + if std::env::var("MOON_TUNER_SEARCH_PROBE").is_ok_and(|v| v.starts_with("search")) { + self.strat_mode = StratMode::Ticks; + } // Addressed by the row KEY — `strategyid@core_uid`, the identity the whole page uses // — not by name: a name is a label, and the same one routinely sits on several cores // with entirely different lists, so naming one picks whichever copy comes first. // A bare `strategyid` is accepted too and takes the first core carrying it. - let want = super::probe_select_spec().unwrap_or(""); + let spec = super::probe_select_spec().unwrap_or(""); + // `select:K1,K2,…` — the first key is the anchor, the rest join it as a Ctrl-click would. + let mut keys = spec.split(','); + let want = keys.next().unwrap_or(""); + let extras: Vec<(String, String)> = self + .strategy_data + .data() + .map(|d| { + // Each by its key, or its bare id on the first core carrying it, as the anchor. + keys.filter_map(|k| { + d.strategies.iter().find(|g| g.key == k).or_else(|| { + d.strategies + .iter() + .find(|g| g.key.split('@').next() == Some(k)) + }) + }) + .map(|g| (g.key.clone(), g.name.clone())) + .collect() + }) + .unwrap_or_default(); let pick = self.strategy_data.data().and_then(|d| { if want.is_empty() { // Nothing addressed: take the BIGGEST blacklist, since the unnamed form @@ -437,6 +461,7 @@ impl AnalyticsView { }); match pick { Some(sel) => { + self.sel_extra = extras; self.set_sel_strategy(Some(sel), cx); true } @@ -666,6 +691,11 @@ impl AnalyticsView { // back. The column below is then simply not built: `left` is already `.flex_1()`, so it // takes the freed width with no width arithmetic anywhere. let side_collapsed = self.side_collapsed; + // "Entry/Exit" draws no grid and no matrix without a selected strategy: its grid would + // list every field of every strategy and its matrix would judge variants of nothing. The + // column stays, empty, and says what it waits for. + let side_empty = + mode == StratMode::Ticks && self.visible_target_count(self.read_core_ids()) == 0; let rail = self.side_rail(p, cx); let mut left = v_flex() .flex_1() @@ -734,8 +764,27 @@ impl AnalyticsView { } StratMode::Ticks if !side_collapsed => { // The matrix on top, the parameter grid with its search row below — the same - // right column as the filter axis. - let side = self.ticks_side(p, window, cx); + // right column as the filter axis; without a strategy, the column's note alone. + let side = if side_empty { + div() + .size_full() + .rounded(design::ui_px(cx, 8.0)) + .bg(moon(p.panel)) + .border_1() + .border_color(moon(p.border)) + .flex() + .items_center() + .justify_center() + .child(crate::load_state::muted( + t!("analytics.ticks.side_pick_strategy").to_string(), + 10.0, + p, + cx, + )) + .into_any_element() + } else { + self.ticks_side(p, window, cx) + }; main = main.child( v_flex() .w(design::font_w_px(cx, 470.0)) diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs index 40a6d9b3..b9de8d65 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs @@ -86,6 +86,7 @@ impl AnalyticsView { (None, None) => (String::new(), p.text_muted), }, }; + super::variants::probe_painted(&status); let placeholder = super::variants::DEFAULT_RESTARTS.to_string(); let it_input = self.shell_cfg_input( TunerKind::Ticks, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index 36524671..3632c26d 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -353,12 +353,16 @@ impl AnalyticsView { .collect(); let (all_on, some_on) = self.ticks_tick_state(&keys); let mut notes: Vec<(String, u32)> = Vec::new(); + // What the heading's tooltip says in place of a note: the short "not modelled" on the + // row, what follows from it on hover. + let mut unmodelled_tip = None; let modelled = section.section.modelled(); if !modelled { notes.push(( t!("analytics.ticks.section_unmodelled").to_string(), p.text_muted, )); + unmodelled_tip = Some(t!("analytics.ticks.section_unmodelled_tip").to_string()); } match data { // Only a LOADED empty scope says so; a load in flight or a failed one has its own @@ -384,11 +388,21 @@ impl AnalyticsView { p.text_muted }; let note = (!notes.is_empty()).then(|| { - notes - .into_iter() - .map(|(text, _)| text) + let text = notes + .iter() + .map(|(text, _)| text.as_str()) .collect::>() - .join(" · ") + .join(" · "); + // The tooltip repeats the row with the unmodelled note spelled out: it is always + // the first one pushed. + let tip = match &unmodelled_tip { + Some(long) => std::iter::once(long.as_str()) + .chain(notes.iter().skip(1).map(|(text, _)| text.as_str())) + .collect::>() + .join(" · "), + None => text.clone(), + }; + (text, tip) }); let id = format!("{:?}", section.section); let which = section.section; @@ -443,7 +457,7 @@ impl AnalyticsView { cx.listener(move |this, _, _, cx| this.ticks_toggle_section(which, cx)), ), ) - .when_some(note, |el, note| { + .when_some(note, |el, (note, tip)| { el.child( div() .id(SharedString::from(format!("an-ticks-sec-note-{id}"))) @@ -451,7 +465,7 @@ impl AnalyticsView { .min_w_0() .truncate() .text_color(moon(color)) - .tooltip(crate::panels::common::text_tooltip(note.clone())) + .tooltip(crate::panels::common::text_tooltip(tip)) .child(note), ) }) diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 4fb0e7f2..328fd01f 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -106,7 +106,7 @@ impl AnalyticsView { // finished at all, and said nothing. if !after_report { self.latest_reads.cancel(&[ReadLane::TicksSearch]); - self.ticks.stop_search(); + self.ticks.stop_search("manual reload"); } let req = self.ticks.seq; let report_req = self.current_report_generation(); @@ -391,7 +391,7 @@ impl AnalyticsView { ReadLane::TicksVariants, ReadLane::TicksSearch, ]); - self.ticks.stop_search(); + self.ticks.stop_search("model settings"); // Every verdict of the table is of the old settings now: none may be carried by a // reload until this stage has judged them all again. self.ticks.judged_under = None; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index 9ee02e71..fe2bf689 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -555,11 +555,11 @@ impl AnalyticsView { ) .to_string(); if i == 0 { - if let Some(holdout) = self + if let Some((holdout, open)) = self .ticks .last_result .as_ref() - .and_then(|r| r.holdout.as_ref()) + .and_then(|r| r.holdout.as_ref().map(|h| (h, r.holdout_open))) { sub = format!( "{sub} · {}", @@ -569,6 +569,10 @@ impl AnalyticsView { profit = super::super::summary::fmt_signed(holdout.profit) ) ); + // Deals held back the answer left open: the holdout cannot count them. + if open > 0 { + sub = format!("{sub} · {}", t!("analytics.ticks.holdout_open", n = open)); + } } } labels.push(VarLabel::with_sub(title, sub)); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index b3f8570b..c61a7e48 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -263,6 +263,7 @@ fn invalidate_stops_the_search_and_drops_the_variant_scores_but_keeps_the_edits( values: Vec::new(), train: Default::default(), holdout: Some(Default::default()), + holdout_open: 0, seed: 1, stats: Default::default(), }); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs index eb655313..8e7eede3 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs @@ -101,9 +101,10 @@ fn the_schema_places_every_field_and_marks_what_the_model_turns() { "TrailingSpread" ] ); - assert_eq!(stops.rows[0].role, RowRole::Fixed); - // The trailing stop is read as the strategy sets it; its spread is the sale's, not the model's. - assert_eq!(stops.rows[3].role, RowRole::Fixed); + // The stop's switch and the trailing stop are knobs since 2026-09-24; the trailing's spread is + // the sale's, not the model's. + assert!(matches!(stops.rows[0].role, RowRole::Knob(p) if p.key == "UseStopLoss")); + assert!(matches!(stops.rows[3].role, RowRole::Knob(p) if p.key == "TrailingPercent")); assert_eq!(stops.rows[4].role, RowRole::Outside); // A section outside the grid is not drawn. @@ -142,10 +143,13 @@ fn two_kinds_share_a_section_without_repeating_a_field() { let shot = vec![section("Stops", &["UseStopLoss", "StopLoss"])]; let hook = vec![section("Stops", &["StopLoss", "StopLossDelay"])]; let out = layout(&[shot.as_slice(), hook.as_slice()], &knobs); - assert_eq!( - keys(find(&out, ParamSection::Stops)), - ["UseStopLoss", "StopLoss", "StopLossDelay"] - ); + let stops = keys(find(&out, ParamSection::Stops)); + // The two kinds' fields first, each once; the section's knobs no schema here places follow. + assert_eq!(stops[..3], ["UseStopLoss", "StopLoss", "StopLossDelay"]); + let mut seen = stops.clone(); + seen.sort(); + seen.dedup(); + assert_eq!(seen.len(), stops.len(), "{stops:?}"); } #[test] diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 4c5e1be3..ed3eb367 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -306,7 +306,7 @@ impl Drop for TicksState { /// A search outlives nothing: closing the window while one runs would otherwise leave it /// on the shared pool to the end, its answer going nowhere. fn drop(&mut self) { - self.stop_search(); + self.stop_search("window closed"); } } @@ -517,22 +517,37 @@ impl TicksState { /// The scope changed: every row belongs to the previous scope. The fetch batch is the /// process's, not the scope's, and runs on; its answers land on rows by id where present. pub(in crate::analytics) fn invalidate(&mut self) { + self.retire_rows(); + // A running search and its last answer describe the previous scope's deals. + self.stop_search("scope change"); + // Nor does the last search's holdout: В1's caption would print it beside a column + // rescored over deals it never saw. + self.last_result = None; + // A note about the previous scope's search says nothing about this one. + self.sugg_note = None; + } + + /// The report axis moved — a core's clock offset measured, or measured again: the rows are + /// read again on the new axis, but the scope is the same strategies over the same period, + /// and a running search goes on. It runs on its own copy of the deals and lands in В1, whose + /// columns are then rescored over the reloaded rows — as across a report that moved + /// (`load.rs`). Stopped here, no search outlived the minutes after a start, while the cores + /// adopt their offsets one by one (2026-09-24: the axis moved every ~30 s). + pub(in crate::analytics) fn invalidate_for_axis(&mut self) { + self.retire_rows(); + } + + /// Retire the rows and everything scored over them; the variant EDITS are the user's and + /// stay, to be rescored over the new rows. + fn retire_rows(&mut self) { self.dirty = true; self.seq = self.seq.wrapping_add(1); self.rows_rev = self.rows_rev.wrapping_add(1); self.order = None; - // The variant KPIs and a running search describe the previous scope's deals; the - // variant EDITS are the user's and stay, to be rescored over the new scope. self.var_seq = self.var_seq.wrapping_add(1); self.var_task = None; self.var_stats = Default::default(); self.plan = Default::default(); - self.stop_search(); - // Nor does the last search's holdout: В1's caption would print it beside a column - // rescored over deals it never saw. - self.last_result = None; - // A note about the previous scope's search says nothing about this one. - self.sugg_note = None; if let Some(data) = self.data.data_mut() { for row in &mut data.rows { if row.tape == TapeStatus::Fetching { @@ -549,8 +564,13 @@ impl TicksState { /// Ask a running search to stop and forget it; its completion is dropped by the /// generation. - pub(in crate::analytics::tuner) fn stop_search(&mut self) { - if let SuggState::Running { handle, .. } = &self.sugg { + pub(in crate::analytics::tuner) fn stop_search(&mut self, reason: &str) { + if let SuggState::Running { handle, total } = &self.sugg { + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks search: stopped by {reason} at {}/{total} restart(s)", + handle.completed() + ); handle.cancel(); } self.sugg = SuggState::Idle; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled/tests.rs index f229a6fd..2e8f6dee 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled/tests.rs @@ -16,14 +16,14 @@ fn values(pairs: &[(&str, &str)]) -> HashMap { /// with nothing — so a strategy absent from it is one the load did not read. #[test] fn the_map_holds_every_strategy_read() { - let ladder = values(&[("UseSecondStop", "YES")]); + let outside = values(&[("UseBV_SV_Stop", "YES")]); let plain = values(&[("SellPrice", "1.5"), ("IgnoreSellShot", "YES")]); let map = unmodelled_map( - [((1, Some(7)), &ladder), ((2, Some(7)), &plain)], + [((1, Some(7)), &outside), ((2, Some(7)), &plain)], &HashMap::new(), &FieldDeps::bundled(), ); - assert_eq!(map[&(1, Some(7))][0].key, "UseSecondStop"); + assert_eq!(map[&(1, Some(7))][0].key, "UseBV_SV_Stop"); // Read and clean is not "not read": the strategy is there, with nothing to say. assert!(map[&(2, Some(7))].is_empty()); assert!(!map.contains_key(&(3, Some(7)))); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index b4babfc7..52171b8b 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -35,9 +35,15 @@ use moon_core::db::tuner::ticks::search::{ }; use moon_core::db::tuner::ticks::stats_of; +mod probe; +pub(super) use probe::painted as probe_painted; + /// How long a burst of cell edits may keep coalescing before the columns are rescored. const VARIANT_DEBOUNCE: Duration = Duration::from_millis(350); +/// How often a running search's row is looked at for a moved restart count. +const SEARCH_POLL: Duration = Duration::from_millis(250); + /// Restarts the search runs with, out of the box's text: the default when empty or /// unreadable, clamped to a sane range. pub(super) fn restarts_of(text: &str) -> usize { @@ -74,6 +80,13 @@ impl AnalyticsView { /// Arm a debounced rescore of the variant columns — every edit of a cell, every row that /// joins the replayable set, goes through here. pub(in crate::analytics::tuner) fn arm_ticks_variants(&mut self, cx: &mut Context) { + if !self.ticks.tape_reading + && self.ticks.data.data().is_some_and(|d| d.fit() > 0) + && probe::fire() + { + probe::dump_schema(self.backend.read(cx).session.store()); + self.ticks_start_search(None, cx); + } self.latest_reads.cancel(&[ReadLane::TicksVariants]); self.ticks.var_seq = self.ticks.var_seq.wrapping_add(1); // Nothing to score: an untouched pair of columns costs no clone of the rows and no @@ -269,6 +282,12 @@ impl AnalyticsView { /// Say why a search did not start. fn ticks_search_refused(&mut self, key: &str, cx: &mut Context) { + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks search: refused, {key}, shares entry {:?} exit {:?}", + self.ticks.data.data().map(|d| d.entry_share), + self.ticks.data.data().map(|d| d.exit_share) + ); self.ticks.sugg_note = Some(t!(key).to_string()); cx.notify(); } @@ -333,7 +352,7 @@ impl AnalyticsView { return self.ticks_search_refused("analytics.ticks.sugg_no_tape", cx); } let defaults = self.filter_defaults(cx); - let restarts = restarts_of(&self.ticks.iters); + let restarts = probe::restarts().unwrap_or_else(|| restarts_of(&self.ticks.iters)); let max_passes = passes_of(&self.ticks.passes); let seed = self.ticks.seed.trim().parse::().ok(); let min_n = self @@ -363,6 +382,14 @@ impl AnalyticsView { self.ticks.sugg_seq = self.ticks.sugg_seq.wrapping_add(1); self.ticks.sugg_note = None; let seq = self.ticks.sugg_seq; + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks search: start #{seq}, {restarts} restart(s) x {max_passes} pass(es) over {} deal(s), entry {vary_entry}, exit {vary_exit}", + pending.len() + ); + probe::watch(handle.clone(), restarts, seq); + self.poll_ticks_search(handle.clone(), seq, cx); + let started = std::time::Instant::now(); self.spawn_latest_db( &[ReadLane::TicksSearch], false, @@ -387,6 +414,16 @@ impl AnalyticsView { suggest(&deals, ¶ms, &handle) }, move |this, result, cx| { + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks search: #{seq} answered after {} ms ({}), current #{}", + started.elapsed().as_millis(), + match &result { + Ok(found) => format!("found {:?}", found.values), + Err(miss) => format!("nothing: {miss:?}"), + }, + this.ticks.sugg_seq + ); if this.ticks.sugg_seq != seq { return; } @@ -414,7 +451,8 @@ impl AnalyticsView { this.arm_ticks_variants(cx); } // Why nothing: the floor no point kept — the typed one, or the search's own - // tenth of the training slice —, the corridor none kept, or nothing at all. + // tenth of the training slice —, the corridor none kept, no point that + // closed every trade it bought, or nothing at all. Err(miss) => { this.ticks.sugg_note = Some(match miss { SearchMiss::Floor => t!( @@ -423,6 +461,7 @@ impl AnalyticsView { ) .to_string(), SearchMiss::Corridor => t!("analytics.ticks.sugg_corridor").to_string(), + SearchMiss::Unclosed => t!("analytics.ticks.sugg_unclosed").to_string(), SearchMiss::Nothing => t!("analytics.ticks.sugg_none").to_string(), }); } @@ -433,9 +472,45 @@ impl AnalyticsView { cx.notify(); } + /// Repaint the search row while the run it follows goes on, each time its restart count + /// moves — nothing else repaints a quiet window, and the count would stand still for the + /// whole run. Ends when the run finished or was replaced (`sugg_seq`); a window closed under + /// it stops the run, which has nobody left to answer. + fn poll_ticks_search(&self, handle: SearchHandle, seq: u64, cx: &mut Context) { + cx.spawn(async move |this, cx| { + let executor = cx.update(|cx| cx.background_executor().clone()); + let mut shown = None; + loop { + executor.timer(SEARCH_POLL).await; + let mut running = false; + let view_gone = cx.update(|cx| { + this.update(cx, |this, cx| { + // The answer sets the row idle without a new generation. + running = this.ticks.sugg_seq == seq + && matches!(this.ticks.sugg, SuggState::Running { .. }); + let done = handle.completed(); + if running && shown != Some(done) { + shown = Some(done); + cx.notify(); + } + }) + .is_err() + }); + if view_gone { + handle.cancel(); + return; + } + if !running { + return; + } + } + }) + .detach(); + } + /// Stop a running search; what it found so far is dropped. pub(in crate::analytics::tuner) fn ticks_stop_suggest(&mut self, cx: &mut Context) { - self.ticks.stop_search(); + self.ticks.stop_search("Stop"); cx.notify(); } @@ -451,6 +526,7 @@ impl AnalyticsView { return; } let mut warns = self.ticks_change_warnings(&changes, cx); + warns.extend(self.ticks_unguarded_warning(&targets, &changes, cx)); warns.extend(self.ticks_unmodelled_warns(&targets, cx)); self.open_change_dialog(targets, changes, None, Vec::new(), warns, false, cx); } @@ -466,6 +542,7 @@ impl AnalyticsView { }; let changes = self.ticks.variant_changes(0); let mut warns = self.ticks_change_warnings(&changes, cx); + warns.extend(self.ticks_unguarded_warning(std::slice::from_ref(&target), &changes, cx)); warns.extend(self.ticks_unmodelled_warns(std::slice::from_ref(&target), cx)); self.open_copy_with(target, changes, warns, window, cx); } @@ -508,13 +585,13 @@ impl AnalyticsView { }) .collect(); let defaults = self.filter_defaults(cx); + let mut warns = Vec::new(); let check = check_corridors( deals.iter().map(|(deal, own)| (*deal, own.as_ref())), &defaults, changes, super::model_cfg::current(), ); - let mut warns = Vec::new(); if check.inverted > 0 { warns.push( t!( @@ -537,4 +614,36 @@ impl AnalyticsView { } warns } + + /// The warning of a write that leaves a strategy it lands on with nothing standing to close a + /// trade — no stop, and no trailing without a take profit: the search refuses such a point + /// (`search::unguarded_strategies`), a variant typed by hand is said to do so. Over the + /// write's own targets the axis has read; one it has not is named by + /// `ticks_unmodelled_warns`. + fn ticks_unguarded_warning( + &self, + targets: &[super::super::shared::SaveTarget], + changes: &[(String, String)], + cx: &Context, + ) -> Vec { + let Some(data) = self.ticks.data.data() else { + return Vec::new(); + }; + let owns: Vec<&HashMap> = targets + .iter() + .filter_map(|t| data.own.get(&(t.sid, t.core?))) + .map(|o| o.as_ref()) + .collect(); + let n = moon_core::db::tuner::ticks::search::unguarded_strategies( + owns.iter().copied(), + &self.filter_defaults(cx), + data.single_kind().unwrap_or_default(), + changes, + super::model_cfg::current(), + ); + if n == 0 { + return Vec::new(); + } + vec![t!("analytics.ticks.unguarded_warn", n = n, m = owns.len()).to_string()] + } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/probe.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/probe.rs new file mode 100644 index 00000000..25265bbb --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/probe.rs @@ -0,0 +1,120 @@ +//! Observation channel for the axis' search (`MOON_TUNER_SEARCH_PROBE=search`), gated by the +//! environment like `MOON_ANALYTICS_PROBE` and inert unless set: the first time a load folds +//! with fit rows, "Search all" is pressed as a click would press it, and the run's progress is +//! logged once a second off the handle — what the search itself has done, beside what the row +//! shows — so a run that stops moving can be read from the log instead of watched by hand. + +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use moon_core::db::tuner::threshold_search::SearchHandle; + +/// The channel's spec when armed: `search` presses at the first fold; `search:` at the first +/// fold that many seconds after the first one — a window left quiet, as a human would find it; +/// `search::` with `n` restarts instead of the box's, for a run long enough to meet +/// whatever the channel is watching for. +fn armed_spec() -> Option<(Duration, Option)> { + static ON: OnceLock)>> = OnceLock::new(); + *ON.get_or_init(|| { + let v = std::env::var("MOON_TUNER_SEARCH_PROBE").ok()?; + let mut parts = v.split(':'); + if parts.next()? != "search" { + return None; + } + let delay = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let restarts = parts.next().and_then(|s| s.parse().ok()); + Some((Duration::from_secs(delay), restarts)) + }) +} + +fn armed_delay() -> Option { + armed_spec().map(|(delay, _)| delay) +} + +/// The restarts the probe's run uses in place of the box's, when the spec names them. +pub(super) fn restarts() -> Option { + armed_spec().and_then(|(_, n)| n) +} + +/// Whether the channel is armed. +fn armed() -> bool { + armed_delay().is_some() +} + +/// Whether the search should be pressed now: once per process, and only when armed. +pub(super) fn fire() -> bool { + static FIRED: AtomicBool = AtomicBool::new(false); + static FIRST: OnceLock = OnceLock::new(); + let Some(delay) = armed_delay() else { + return false; + }; + FIRST.get_or_init(std::time::Instant::now).elapsed() >= delay + && !FIRED.swap(true, Ordering::Relaxed) +} + +/// Log a run's progress once a second, on a thread of its own, until it finishes or stops — +/// only when armed. +pub(super) fn watch(handle: SearchHandle, total: usize, seq: u64) { + if !armed() { + return; + } + let _ = std::thread::Builder::new() + .name("ticks-search-probe".into()) + .spawn(move || { + let mut last = usize::MAX; + loop { + std::thread::sleep(Duration::from_secs(1)); + let done = handle.completed(); + if done != last { + last = done; + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks search probe: #{seq} {done}/{total} restart(s) done" + ); + } + if done >= total || handle.is_cancelled() { + break; + } + } + }); +} + +/// Log the search row's status as the frame paints it — only when it changed, and only when +/// armed: what the row SHOWS, beside what the run has done ([`watch`]). +pub(in super::super) fn painted(status: &str) { + if !armed() { + return; + } + static LAST: std::sync::Mutex = std::sync::Mutex::new(String::new()); + let Ok(mut last) = LAST.lock() else { return }; + if *last != status { + status.clone_into(&mut last); + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks search probe: painted {status:?}" + ); + } +} + +/// Every kind's sections and their fields as the first connected core's live schema files them, +/// one line per section — the answer to "does this kind have that section", read off the core. +pub(super) fn dump_schema(store: &moon_core::session::CoreStore) { + let Some(schema) = store.cores().find_map(|(_, core)| core.schema.as_ref()) else { + log::info!(target: moon_core::diagnostics::TICKS_AXIS_TARGET, "[x] ticks schema: none"); + return; + }; + for kind in &schema.kinds { + for section in &kind.sections { + let fields: Vec<&str> = section.fields.iter().map(|f| f.name.as_str()).collect(); + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks schema: {} ({}) / {}: {}", + kind.name, + kind.ordinal, + section.title, + fields.join(",") + ); + } + } +} diff --git a/crates/moon-ui-gpui/tests/theme_contract/analytics.rs b/crates/moon-ui-gpui/tests/theme_contract/analytics.rs index da68588d..a9127ebd 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/analytics.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/analytics.rs @@ -4,10 +4,14 @@ use super::support::*; /// `analytics/mod.rs:observe_report_axis` must refresh through the Writer path and must call -/// `TunerState::invalidate_for_axis`, while `observe_valuation_mode` remains a real scope reload. +/// `TunerState::invalidate_for_axis` and `TicksState::invalidate_for_axis`, while +/// `observe_valuation_mode` remains a real scope reload. /// /// Breakage: restoring `self.tuner.invalidate();` cancels a live field-set composition. The -/// spinner vanishes minutes into "Pick the set", with no error and no caption. The time, coin, +/// spinner vanishes minutes into "Pick the set", with no error and no caption. Restoring +/// `self.ticks.invalidate();` — or cancelling the search lane with the rest — stops the +/// Entry/Exit search whenever a core adopts its clock offset, which after a start is every ~30 s: +/// no search longer than that ever finished (2026-09-24). The time, coin, /// and coin-list axes still call `invalidate()`; dropping one of those leaves that axis's drafts /// alive across an axis adoption. Merging the axis path into `reload(` blanks Analytics on every /// feed reconnect; removing the valuation reload leaves a mode change under stale values. @@ -25,6 +29,11 @@ fn report_axis_observation_uses_writer_refresh_while_valuation_mode_reloads() { !report_axis.contains("self.tuner.invalidate();"), "a report-axis observation must not cancel a running composition" ); + assert!( + !report_axis.contains("self.ticks.invalidate();") + && !report_axis.contains("self.cancel_latest_reads();"), + "a report-axis observation must not stop the Entry/Exit search" + ); assert!( report_axis.contains("self.request_report_refresh(") && report_axis.contains("RefreshUrgency::Writer,") @@ -34,8 +43,9 @@ fn report_axis_observation_uses_writer_refresh_while_valuation_mode_reloads() { for required in [ "self.seq = self.seq.wrapping_add(1);", "self.cal_seq = self.cal_seq.wrapping_add(1);", - "self.cancel_latest_reads();", + "self.cancel_reads_for_axis_move();", "self.tuner.invalidate_for_axis();", + "self.ticks.invalidate_for_axis();", "self.time_tuner.invalidate();", "self.coins.invalidate();", "self.coin_lists.invalidate();", diff --git a/locales/analytics.yml b/locales/analytics.yml index d5184855..802e4adb 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1940,10 +1940,18 @@ analytics.ticks.row_unmodelled: ru: "Раздел не моделируется — поле модель не учитывает" en: "The section is not modelled — the model does not take this field into account" es: "La sección no se modela — el modelo no tiene en cuenta este campo" +analytics.ticks.side_pick_strategy: + ru: "Выберите стратегию в списке — параметры и «Факт vs варианты» считаются по выбранным" + en: "Pick a strategy in the list — the parameters and \"Fact vs variants\" are computed over the selected ones" + es: "Elija una estrategia en la lista: los parámetros y «Hecho vs variantes» se calculan sobre las seleccionadas" analytics.ticks.section_unmodelled: - ru: "не моделируется: сделки стратегий с включённым разделом не судятся и в подбор не идут" - en: "not modelled: trades of a strategy that switches it on are not judged and stay out of the search" - es: "no se modela: las operaciones de una estrategia que lo activa no se juzgan y quedan fuera de la búsqueda" + ru: "не моделируется" + en: "not modelled" + es: "no se modela" +analytics.ticks.section_unmodelled_tip: + ru: "Не моделируется: сделки стратегий с включённым разделом не судятся и в подбор не идут" + en: "Not modelled: trades of a strategy that switches it on are not judged and stay out of the search" + es: "No se modela: las operaciones de una estrategia que lo activa no se juzgan y quedan fuera de la búsqueda" analytics.ticks.unmodelled_title: ru: "Параметры выхода, которых нет в модели" en: "Exit parameters the model does not have" @@ -2020,6 +2028,18 @@ analytics.ticks.stats_cut: ru: "не сошёлся за %{n} прох. — упёрся в предел" en: "still improving after %{n} passes — cut by the limit" es: "seguía mejorando tras %{n} pasadas — cortado por el límite" +analytics.ticks.sugg_unclosed: + ru: "Ни один вариант не закрывает сделку внутри ленты: нужен стоп или трейлинг без ТП, и каждая купленная сделка должна закрыться стопом или селлом" + en: "No variant closes its trades inside the tape: a stop or a trailing without a take profit is needed, and every trade bought must close by its stop or its sell" + es: "Ninguna variante cierra sus operaciones dentro de la cinta: hace falta un stop o un trailing sin take profit, y cada operación comprada debe cerrarse por su stop o su venta" +analytics.ticks.holdout_open: + ru: "открытыми остались %{n}" + en: "%{n} left open" + es: "%{n} quedaron abiertas" +analytics.ticks.unguarded_warn: + ru: "Без защиты: %{n} из %{m} стратегий — ни стопа, ни трейлинга без ТП; сделка может не закрыться" + en: "Unguarded: %{n} of %{m} strategies — no stop and no trailing without a take profit; a trade may never close" + es: "Sin protección: %{n} de %{m} estrategias — sin stop ni trailing sin take profit; una operación puede no cerrarse" analytics.ticks.sugg_corridor: ru: "Ни один вариант не держит коридор: MShotPriceMin меньше MShotPrice и, с галкой, не ближе к цене, чем у сделки" en: "No variant keeps the corridor: MShotPriceMin below MShotPrice and, with the switch on, no nearer the price than the trade's" From 6421f4e1e16a7257fb047c34b3672b697b027819 Mon Sep 17 00:00:00 2001 From: guyverino Date: Thu, 24 Sep 2026 22:44:37 +0200 Subject: [PATCH 37/51] feat(tuner): grid shows fields in use, honest base, tail filter, finer 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. --- crates/moon-core/src/config/layout.rs | 4 + crates/moon-core/src/db/tuner/ticks/params.rs | 27 ++- .../src/db/tuner/ticks/params/tests.rs | 20 ++ crates/moon-core/src/db/tuner/ticks/search.rs | 100 ++++++---- .../src/db/tuner/ticks/search/closing.rs | 59 +++++- .../db/tuner/ticks/search/closing/tests.rs | 47 +++++ .../src/db/tuner/ticks/search/deps.rs | 123 ++++++++++-- .../src/db/tuner/ticks/search/deps/tests.rs | 160 ++++++++++++++- .../src/db/tuner/ticks/unmodelled.rs | 106 +++++++++- .../src/db/tuner/ticks/unmodelled/tests.rs | 110 +++++++++++ crates/moon-ui-gpui/src/analytics/mod.rs | 1 + .../src/analytics/tuner/ticks/cfg.rs | 61 +++--- .../src/analytics/tuner/ticks/grid.rs | 24 +-- .../src/analytics/tuner/ticks/load.rs | 50 ++--- .../src/analytics/tuner/ticks/mod.rs | 4 +- .../src/analytics/tuner/ticks/rows/tests.rs | 23 ++- .../src/analytics/tuner/ticks/sections.rs | 101 ++++++++-- .../analytics/tuner/ticks/sections/tests.rs | 92 ++++++++- .../src/analytics/tuner/ticks/state.rs | 12 +- .../src/analytics/tuner/ticks/tail.rs | 185 ++++++++++++++++++ .../src/analytics/tuner/ticks/tail/tests.rs | 26 +++ .../src/analytics/tuner/ticks/variants.rs | 98 ++++++++-- .../analytics/tuner/ticks/variants/tests.rs | 57 ++++++ locales/analytics.yml | 30 ++- 24 files changed, 1321 insertions(+), 199 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/params/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/tail.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/tail/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index a7734044..1622c12c 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -651,6 +651,10 @@ pub struct TicksAxisLayout { /// own. Off by default — and stored this way round so that a config written before the /// switch existed reads it off, the guard on (`SearchParams::keep_corridor`). pub allow_closer_corridor: bool, + /// The shortest tape past the close, seconds, a deal must hold to be worked on — the sample + /// the variant columns and the search run on; `None` = the axis default. The tape of an + /// older trade cannot be fetched again, and one short tail cut every variant's exit at it. + pub min_tail_s: Option, } /// Complete window layout. diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index dbdcc9fa..b5882ece 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -111,14 +111,22 @@ const NOT_SELL_PRICE: &[&str] = &[ ]; const ANY: &[&str] = &[]; -const GRID_PRICE: &[f64] = &[ - 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 6.5, - 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, -]; -const GRID_PRICE_MIN: &[f64] = &[ - 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.25, 1.5, - 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, -]; +/// The corridor's two fields, `MShotPrice` and `MShotPriceMin`: every 0.05 of a per cent from +/// 0.05 to 8 (LinKvo, 2026-09-24) — a strategy's own 1.7 is a step, not a snap to 1.75. +const GRID_PRICE: &[f64] = &twentieths::<160>(); +const GRID_PRICE_MIN: &[f64] = GRID_PRICE; + +/// `5/100, 10/100, … 5·N/100`: each value divided rather than summed, so 1.7 is exactly the +/// `1.7` a strategy spells, with no accumulated rounding. +const fn twentieths() -> [f64; N] { + let mut out = [0.0; N]; + let mut i = 0; + while i < N { + out[i] = ((i + 1) * 5) as f64 / 100.0; + i += 1; + } + out +} const GRID_WAIT_S: &[f64] = &[0.0, 0.1, 0.3, 0.5, 1.0, 2.0, 5.0]; const GRID_ADJUST: &[f64] = &[ -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, @@ -764,3 +772,6 @@ pub(super) fn unmodelled_rule(v: &StrategyValues<'_>) -> Option None } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/params/tests.rs b/crates/moon-core/src/db/tuner/ticks/params/tests.rs new file mode 100644 index 00000000..304b1ed4 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/params/tests.rs @@ -0,0 +1,20 @@ +//! The knobs' grids. + +use super::*; + +/// The corridor's grids step by 0.05 to 8, and hold a strategy's own spellings exactly: 1.7 is a +/// step, spelled back as `1.7`, not snapped to 1.75. +#[test] +fn the_corridor_grids_step_by_a_twentieth_to_eight() { + for key in ["MShotPrice", "MShotPriceMin"] { + let field = TICK_PARAMS.iter().find(|f| f.key == key).expect("a knob"); + let ParamKind::Num { grid } = &field.kind else { + panic!("{key} is a number"); + }; + assert_eq!(grid.len(), 160, "{key}"); + assert_eq!(grid.first().copied(), Some(0.05)); + assert_eq!(grid.last().copied(), Some(8.0)); + assert!(grid.contains(&1.7) && grid.contains(&1.2), "{key}"); + assert_eq!(format!("{}", grid[33]), "1.7"); + } +} diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index f39f82ea..4febc129 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -179,6 +179,9 @@ pub struct SearchStats { /// Restarts that ended on a point the corridor rules or the closing rule (`closing`) refuse /// — none of their moves reached an allowed one. pub refused: usize, + /// Deals the strategies as they stand leave open inside the tape, taken out of the sample + /// before the search (`closing::closable_at_base`). + pub left_open: usize, } /// What the search found. @@ -447,21 +450,10 @@ fn arity(kind: &ParamKind) -> usize { } } -/// The fields one search varies. +/// The fields one search varies: those it offers ([`deps::offered`]) less the locked. fn varied<'a>(p: &SearchParams<'a>) -> Vec<&'static super::params::TickParam> { - TICK_PARAMS - .iter() - .filter(|f| match f.group { - ParamGroup::Entry => p.vary_entry, - ParamGroup::Exit => p.vary_exit, - }) - .filter(|f| f.kinds.is_empty() || f.kinds.contains(&p.kind)) - // The same gate the grid applies: a field this kind's model does not read would be - // varied for nothing, land in a variant column the grid cannot show, and be written by - // Save all the same. - .filter(|f| !f.not_kinds.contains(&p.kind)) - // A field the entry method does not read moves nothing either. - .filter(|f| p.model.entry_method.reads(f.key)) + deps::offered(p) + .into_iter() .filter(|f| !p.locked.contains(f.key)) .collect() } @@ -733,6 +725,15 @@ pub fn suggest( handle: &SearchHandle, ) -> Result { let fields = varied(params); + // The strategies of the WHOLE sample stay the bases throughout, a strategy whose every deal + // leaves the sample below among them: where each number field starts, what completes a point + // (`deps`) and the parameters built per base are then the same for the filter and for every + // point scored after it, restart 0's included. + let whole = Bases::of(deals); + let (start, deps) = deps::dependents_of(params, &whole.owns); + // A deal the strategies as they stand leave open is out of the sample (`closing`). + let (kept, of_kept, left_open) = closing::closable_at_base(deals, &whole, params, &deps); + let deals = kept.as_slice(); if deals.is_empty() || fields.is_empty() { return Err(SearchMiss::Nothing); } @@ -753,7 +754,10 @@ pub fn suggest( let restarts = params.restarts.max(1); let max_passes = params.max_passes.max(1); let model = params.model.sanitized(); - let bases = Bases::of(deals); + let bases = Bases { + owns: whole.owns, + of_deal: of_kept, + }; let train_of = &bases.of_deal[..train_n]; // Held over the whole sample, the holdout included: a corridor nearer the price than a // trade's own is out whichever side of the cut the trade sits on. @@ -773,34 +777,6 @@ pub fn suggest( .iter() .map(|(entry, _)| ordered(entry)) .collect(); - // Where each number field starts on its grid — the median of what the selected strategies - // hold (the held value over all of them; the schema default for one that leaves it out), - // snapped to the nearest grid step: what a pair move and a perturbed start step from while - // the point leaves the field alone. A move sets one value for every strategy, so it steps - // from the middle of theirs rather than from whichever came first. - let parse = |text: &String| text.trim().replace(',', ".").parse::().ok(); - let start: HashMap<&'static str, usize> = fields - .iter() - .filter_map(|f| { - let ParamKind::Num { grid } = &f.kind else { - return None; - }; - let default = params.defaults.get(f.key).copied(); - let mut values: Vec = match params.held.get(f.key).and_then(parse) { - Some(held) => vec![held], - None => bases - .owns - .iter() - .filter_map(|own| own.get(f.key).and_then(parse).or(default)) - .collect(), - }; - values.sort_by(f64::total_cmp); - let value = values.get(values.len() / 2).copied().or(default)?; - Some((f.key, nearest_step(grid, value))) - }) - .collect(); - // The Strategies window's dependencies: what a point switches on it gives a value. - let deps = deps::Dependents::new(&fields, &start); let evaluations = std::sync::atomic::AtomicUsize::new(0); // Why points were refused, for the answer's reason when none is left. let (cornered, unclosed) = ( @@ -918,8 +894,46 @@ pub fn suggest( distinct, evaluations: evaluations.load(std::sync::atomic::Ordering::Relaxed), refused, + left_open: left_open.len(), }; let (point, score) = (best.point, best.score); + // The strategies as they stand against the answer, on the slice both were fitted on: a best + // below its own base is a search that could not reach the base, and the log says so. + let base_score = evaluate(&Point::new()); + let brief = |t: &Option| { + t.as_ref() + .map(|t| (t.n, (t.profit * 100.0).round() / 100.0)) + }; + // Which rule refused the base, when one did: how many deals' corridors it comes nearer + // than, whether it inverts, whether it is guarded. + let base_why = base_score.is_none().then(|| { + let full = deps.complete(&Point::new(), &bases.owns, params.held, params.defaults); + let per_base = bases.params(params.held, params.defaults, &full, params.kind, model); + let nearer = guard.as_ref().map(|g| { + g.deals + .iter() + .filter(|(i, own, deltas)| { + !keeps_corridor(&per_base[bases.of_deal[*i]].0, own, deltas) + }) + .count() + }); + ( + params.vary_entry && inverts(&start_ordered, &per_base), + nearer, + closing::protected(&per_base), + ) + }); + log::info!( + target: crate::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks search: base (n, profit) {:?} against best {:?} over {} training deal(s), {} left out; base refused by (inverts, deals nearer than their own corridor, guarded) {:?}; points refused by the corridor {}, by a deal left open {}", + brief(&base_score), + brief(&score), + train_n, + left_open.len(), + base_why, + cornered.load(std::sync::atomic::Ordering::Relaxed), + unclosed.load(std::sync::atomic::Ordering::Relaxed) + ); // The answer as it was scored — every field it switched on at a value — less what is in // effect on no strategy. let point = deps.prune( diff --git a/crates/moon-core/src/db/tuner/ticks/search/closing.rs b/crates/moon-core/src/db/tuner/ticks/search/closing.rs index 87bafe32..953d33b9 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/closing.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/closing.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; -use super::{PreparedDeal, Tally, params_of, point_of, results}; +use super::{Bases, Point, PreparedDeal, SearchParams, Tally, params_of, point_of, results}; use crate::db::tuner::ticks::EntryParams; use crate::db::tuner::ticks::exit::ExitParams; use crate::db::tuner::ticks::settings::ModelSettings; @@ -71,6 +71,63 @@ pub(super) fn closed_tally( Some(tally) } +/// The sample less the deals the strategies as they stand leave open inside the tape — the +/// variant's held edits over them and nothing else moved, completed as every point is +/// (`deps::Dependents::complete`), over the same bases the search then scores on: restart 0's +/// very point — with the kept deals' indices into `bases.owns`, and the dropped deals' ids. +/// +/// Such a deal is no point's doing: a gap in its tape, a rule the model does not have. Held in the +/// sample it would refuse every point, the strategy itself among them, and the search could not +/// move even the one field it was asked about (LinKvo, 2026-09-24: "the stops are in the strategy +/// and must stay; only the selected field is searched"). A point is still refused when it leaves +/// open a deal the strategies as they stand close ([`closed_tally`]). +/// +/// Args: +/// deals: The sample, cut at its horizon. +/// bases: The sample's bases ([`Bases::of`] over `deals`), kept by the search as they are. +/// params: The search's parameters: the held edits, the defaults, the kind, the model. +/// deps: The search's completion ([`super::deps::dependents_of`]) over `bases`. +pub(super) fn closable_at_base( + deals: &[PreparedDeal], + bases: &Bases<'_>, + params: &SearchParams<'_>, + deps: &super::deps::Dependents, +) -> (Vec, Vec, Vec) { + let point = deps.complete(&Point::new(), &bases.owns, params.held, params.defaults); + let base = bases.params( + params.held, + params.defaults, + &point, + params.kind, + params.model.sanitized(), + ); + let mut kept = Vec::with_capacity(deals.len()); + let mut of_kept = Vec::with_capacity(deals.len()); + let mut left_open = Vec::new(); + for (((_, open), deal), &base_of) in results(deals, &bases.of_deal, &base) + .into_iter() + .zip(deals) + .zip(&bases.of_deal) + { + if open { + left_open.push(deal.deal.report_uid); + } else { + kept.push(deal.clone()); + of_kept.push(base_of); + } + } + if !left_open.is_empty() { + log::info!( + target: crate::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks search: {} of {} deal(s) the strategies as they stand leave open inside the tape are out of the sample: {:?}", + left_open.len(), + deals.len(), + left_open + ); + } + (kept, of_kept, left_open) +} + /// The tally of a point over `deals` — the deals it closed — and how many it bought and left /// open inside the tape, which the tally cannot hold. pub(super) fn tally_counting_open( diff --git a/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs index 24707459..7eb5ee53 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs @@ -90,3 +90,50 @@ fn a_point_that_leaves_a_deal_open_is_refused() { ); assert_eq!(result.train.n, 6, "every deal closed, by its stop"); } + +/// One deal the strategy as it stands does not close inside its tape — a flat tape that reaches +/// neither the take nor the stop — no longer refuses every point: it leaves the sample, the +/// answer says so, and the one field asked about is searched over the rest (LinKvo, 2026-09-24). +#[test] +fn a_deal_the_strategy_itself_leaves_open_leaves_the_sample() { + let mut deals: Vec = (1..=6).map(|uid| prepared(uid, 101.0)).collect(); + let mut flat = prepared(7, 101.0); + let t0 = flat.deal.buy_ms; + flat.ticks = Arc::from(vec![ + tick(t0 - 500, 100.0), + tick(t0, 100.0), + tick(t0 + 900, 99.9), + ]); + deals.push(flat); + let (held, defaults) = (HashMap::new(), HashMap::new()); + let locked: HashSet = TICK_PARAMS + .iter() + .map(|f| f.key.to_string()) + .filter(|k| k != "SellPrice") + .collect(); + let params = SearchParams { + held: &held, + defaults: &defaults, + kind: "PumpsDetection", + vary_entry: false, + vary_exit: true, + locked: &locked, + restarts: 2, + min_n: Some(3), + seed: Some(5), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + }; + let result = suggest(&deals, ¶ms, &SearchHandle::new()).expect("a point over the rest"); + assert_eq!(result.stats.left_open, 1); + assert_eq!(result.train.n, 6); + assert_eq!( + result.values, + vec![("SellPrice".to_string(), "1".to_string())] + ); +} diff --git a/crates/moon-core/src/db/tuner/ticks/search/deps.rs b/crates/moon-core/src/db/tuner/ticks/search/deps.rs index b84e3d1b..cd4ae3a4 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/deps.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/deps.rs @@ -4,23 +4,39 @@ //! //! Two rules keep a point honest about them: //! -//! - a number field the point puts in effect on a strategy that holds no value for it is set on -//! the point — the step the search starts it from, the median of the values the strategies -//! hold — so a switch is never turned on with nothing behind it (a `UseTakeProfit = YES` whose -//! per cent is on no record: the developer, 2026-09-24). Like every value of a variant it is -//! one value for every strategy of the point — Save writes it to each — so a strategy that held -//! its own is scored at it too, and the point is scored as it will be written; +//! - a number field the VARIANT puts in effect — the point or its held edits — on a strategy that +//! holds no value for it is set on the point: the step the search starts it from, the median of +//! the values the strategies hold. A switch is never turned on with nothing behind it (a +//! `UseTakeProfit = YES` whose per cent is on no record: the developer, 2026-09-24). Like every +//! value of a variant it is one value for every strategy of the point — Save writes it to each — +//! so a strategy that held its own is scored at it too, and the point is scored as it will be +//! written; //! - a field in effect on no strategy is left out of the answer: it moves nothing. +//! +//! A field already in effect on the strategy as it stands is never completed, however its value +//! is missing from the dump: the core leaves out a field at its default, so absent there means +//! "at the core's default", not "no value". Completing it with the other strategies' median +//! rewrote the strategy itself — `MShotAddBTCDelta` 0.03 laid on a strategy at 0 moved its +//! corridor nearer the price than its own trades ran, and the corridor rule refused the strategy +//! as it stands (24.09, 53 of 136 deals). +//! +//! The first rule reaches the fields the search does not vary too ([`offered`]): a search of +//! `UseTakeProfit` alone locks every other field, `TakeProfit` among them, and a switch it turns on +//! must still bring its per cent. use std::collections::HashMap; -use super::{Point, spell}; -use crate::db::tuner::ticks::params::{ParamKind, TickParam}; +use super::{Point, SearchParams, spell}; +use crate::db::tuner::ticks::TICK_PARAMS; +use crate::db::tuner::ticks::params::{ParamGroup, ParamKind, TickParam}; use crate::feed::strategy_deps::{FieldDeps, Values}; /// The value a condition field reads when a strategy's dump leaves it out and no live schema /// says otherwise — the model's own fallbacks (`params::exit_params`, `ExitParams::default`), so -/// "in effect" is the same question the model answers when it reads the field. +/// "in effect" is the same question the model answers when it reads the field. Every condition a +/// number knob's rule reads is here — a unit test holds this against the bundled rules: one left +/// out reads as absent, which does not block, and the strategy as it stands would seem to have +/// the field in effect behind a switch that is off. const CONDITION_FALLBACKS: &[(&str, &str)] = &[ ("hodlmode", "NO"), ("autosell", "YES"), @@ -32,9 +48,72 @@ const CONDITION_FALLBACKS: &[(&str, &str)] = &[ ("pricedowntimer", "0"), ("sellleveldelay", "0"), ("sellleveltime", "0"), + ("mshotsellatlastprice", "NO"), ]; -/// The varied number fields and the step each is completed at. +/// The fields of the searched groups this kind and entry method read, the locked ones among +/// them: what a point can switch on, and so what [`Dependents`] completes. +pub(super) fn offered<'a>(p: &SearchParams<'a>) -> Vec<&'static TickParam> { + TICK_PARAMS + .iter() + .filter(|f| match f.group { + ParamGroup::Entry => p.vary_entry, + ParamGroup::Exit => p.vary_exit, + }) + .filter(|f| f.kinds.is_empty() || f.kinds.contains(&p.kind)) + // The same gate the grid applies: a field this kind's model does not read would be + // varied for nothing, land in a variant column the grid cannot show, and be written by + // Save all the same. + .filter(|f| !f.not_kinds.contains(&p.kind)) + // A field the entry method does not read moves nothing either. + .filter(|f| p.model.entry_method.reads(f.key)) + .collect() +} + +/// Where each number field of the search starts on its grid, and the completion over those +/// steps. +/// +/// A field starts at the median of what the strategies hold (the held value over all of them; +/// the schema default for one that leaves it out), snapped to the nearest grid step: what a pair +/// move and a perturbed start step from while the point leaves the field alone. A move sets one +/// value for every strategy, so it steps from the middle of theirs rather than from whichever +/// came first. The locked fields get one too: it is where a switch the variant turns on +/// completes them. +/// +/// Args: +/// params: The search's parameters. +/// owns: Each strategy's own values, once per strategy (`Bases::owns`). +pub(super) fn dependents_of( + params: &SearchParams<'_>, + owns: &[&HashMap], +) -> (HashMap<&'static str, usize>, Dependents) { + let parse = |text: &String| text.trim().replace(',', ".").parse::().ok(); + let offered = offered(params); + let start: HashMap<&'static str, usize> = offered + .iter() + .filter_map(|f| { + let ParamKind::Num { grid } = &f.kind else { + return None; + }; + // The schema's defaults are keyed lowercase (`strategy_field_defaults`). + let default = params.defaults.get(&f.key.to_ascii_lowercase()).copied(); + let mut values: Vec = match params.held.get(f.key).and_then(parse) { + Some(held) => vec![held], + None => owns + .iter() + .filter_map(|own| own.get(f.key).and_then(parse).or(default)) + .collect(), + }; + values.sort_by(f64::total_cmp); + let value = values.get(values.len() / 2).copied().or(default)?; + Some((f.key, super::nearest_step(grid, value))) + }) + .collect(); + let dependents = Dependents::new(&offered, &start); + (start, dependents) +} + +/// The number fields a point may complete and the step each is completed at. pub(super) struct Dependents { rules: FieldDeps, numbers: Vec<(&'static TickParam, String)>, @@ -42,8 +121,9 @@ pub(super) struct Dependents { impl Dependents { /// Args: - /// fields: The fields the search varies. - /// start: Where each number field starts on its grid. + /// fields: The fields the search offers ([`offered`]), varied or locked. + /// start: Where each number field starts on its grid; one without a start is never + /// completed. pub(super) fn new(fields: &[&'static TickParam], start: &HashMap<&'static str, usize>) -> Self { let numbers = fields .iter() @@ -56,8 +136,10 @@ impl Dependents { } } - /// `point` with every varied number field it puts in effect on a strategy that holds no value - /// for it set at its start step — for every strategy, as a variant's values are. + /// `point` with every number field the variant puts in effect on a strategy that holds no + /// value for it set at its start step — for every strategy, as a variant's values are. A + /// field the strategy as it stands already has in effect is at the core's default there, and + /// is left alone. pub(super) fn complete( &self, point: &Point, @@ -66,17 +148,26 @@ impl Dependents { defaults: &HashMap, ) -> Point { let mut out = point.clone(); + // Each strategy's values as it stands, before the variant — read once per strategy, and + // only when a field comes into question: they do not depend on the point. + let mut stored: Vec> = vec![None; owns.len()]; // A completed number can be another's condition (`PriceDownTimer<>0`) on any strategy: // every strategy is read again until nothing more comes into effect. loop { let before = out.len(); - for own in owns { + for (index, own) in owns.iter().enumerate() { let values = effective(own, held, &out, defaults); for (field, at_start) in &self.numbers { let missing = !out.contains_key(field.key) && !held.contains_key(field.key) && !own.contains_key(field.key); - if missing && self.rules.field_active(field.key, &values) { + if !missing || !self.rules.field_active(field.key, &values) { + continue; + } + let base = stored[index].get_or_insert_with(|| { + effective(own, &HashMap::new(), &Point::new(), defaults) + }); + if !self.rules.field_active(field.key, base) { out.insert(field.key, at_start.clone()); } } diff --git a/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs index a01e5c3d..78017f39 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs @@ -1,5 +1,7 @@ //! The search's field dependencies on hand-made strategies. +use std::collections::HashSet; + use super::*; use crate::db::tuner::ticks::TICK_PARAMS; @@ -94,8 +96,9 @@ fn a_completed_value_is_the_points_for_every_strategy() { /// A number one strategy lacks can put another's dependent in effect: the first strategy keeps /// PriceDown off (`PriceDownTimer = 0`) and holds no per cent, the second holds the per cent and no -/// timer. Completing the second's timer switches PriceDown on for the first as well, and its per -/// cent is then completed too — whatever order the strategies come in. +/// timer — at the core's default, which the dump leaves out. Neither is completed as they stand; +/// a point that switches PriceDown on brings the first one's per cent, whatever order the +/// strategies come in. #[test] fn a_completion_reaches_every_strategy_whatever_the_order() { let fields = [field("PriceDownTimer"), field("PriceDownPercent")]; @@ -107,7 +110,158 @@ fn a_completion_reaches_every_strategy_whatever_the_order() { let pct = own(&[("PriceDownPercent", "10")]); for owns in [[&off, &pct], [&pct, &off]] { let out = deps.complete(&point(&[]), &owns, &HashMap::new(), &HashMap::new()); - assert!(out.contains_key("PriceDownTimer"), "{out:?}"); + assert!(out.is_empty(), "{out:?}"); + let on = point(&[("PriceDownTimer", "5")]); + let out = deps.complete(&on, &owns, &HashMap::new(), &HashMap::new()); assert!(out.contains_key("PriceDownPercent"), "{out:?}"); } } + +/// A field a strategy leaves out is at the core's default, not missing: the strategies as they +/// stand get nothing from the others. `MShotAddBTCDelta` 0.03 on one strategy and absent on +/// another — completing the second at the median rewrote its corridor, and the corridor rule then +/// refused the strategy itself (24.09). +#[test] +fn a_field_at_its_default_is_not_completed_from_other_strategies() { + let fields = [field("MShotAddBTCDelta")]; + let start: HashMap<&'static str, usize> = [("MShotAddBTCDelta", 1)].into_iter().collect(); + let deps = Dependents::new(&fields, &start); + let set = own(&[("MShotAddBTCDelta", "0.03")]); + let bare = own(&[]); + let out = deps.complete( + &point(&[]), + &[&set, &bare], + &HashMap::new(), + &HashMap::new(), + ); + assert!(out.is_empty(), "{out:?}"); +} + +/// A search of one field locks every other, the per cent of a switch the variant turns on among +/// them — and the answer still carries that per cent: В1 holds `UseTakeProfit = YES` on a +/// trailing strategy that keeps no `TakeProfit`, the search of `SellPrice` alone brings it at the +/// schema default's step (the defaults are keyed lowercase, as `strategy_field_defaults` gives +/// them). +#[test] +fn a_search_of_one_field_completes_what_the_variant_switched_on() { + use crate::db::tuner::threshold_search::SearchHandle; + use crate::db::tuner::ticks::search::{ + DEFAULT_MAX_PASSES, SearchParams, suggest, tests::prepared, + }; + use crate::db::tuner::ticks::settings::ModelSettings; + use std::sync::Arc; + + let trailing: Arc> = Arc::new(own(&[ + ("SellPrice", "0.2"), + ("StopLoss", "-50"), + ("UseTrailing", "YES"), + ("TrailingPercent", "-1"), + ])); + let deals: Vec<_> = (1..=8) + .map(|uid| { + let mut deal = prepared(uid, 101.0); + deal.own = Arc::clone(&trailing); + deal + }) + .collect(); + let held = own(&[("UseTakeProfit", "YES")]); + let defaults: HashMap = [("takeprofit".to_string(), 1.0)].into_iter().collect(); + let locked: HashSet = TICK_PARAMS + .iter() + .map(|f| f.key.to_string()) + .filter(|k| k != "SellPrice") + .collect(); + let params = SearchParams { + held: &held, + defaults: &defaults, + kind: "PumpsDetection", + vary_entry: false, + vary_exit: true, + locked: &locked, + restarts: 1, + min_n: Some(4), + seed: Some(3), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + }; + let result = suggest(&deals, ¶ms, &SearchHandle::new()).expect("a result"); + let take = result + .values + .iter() + .find(|(k, _)| k == "TakeProfit") + .map(|(_, v)| v.parse::().expect("a number")); + assert_eq!(take, Some(1.0), "{:?}", result.values); + // Nothing else locked moved: the searched field and the completed per cent. + assert!( + result + .values + .iter() + .all(|(k, _)| k == "SellPrice" || k == "TakeProfit"), + "{:?}", + result.values + ); +} + +/// A strategy that already keeps the take profit on with no per cent on record is left as it +/// stands — the search did not switch anything on, the per cent is at the core's default — while +/// one the variant switches on gets the per cent. +#[test] +fn a_field_is_completed_only_for_a_switch_the_variant_turns_on() { + let fields = [ + field("UseTrailing"), + field("UseTakeProfit"), + field("TakeProfit"), + ]; + let start: HashMap<&'static str, usize> = [("TakeProfit", 2)].into_iter().collect(); + let deps = Dependents::new(&fields, &start); + let stored_on = own(&[("UseTrailing", "YES"), ("UseTakeProfit", "YES")]); + let out = deps.complete(&point(&[]), &[&stored_on], &HashMap::new(), &HashMap::new()); + assert!(!out.contains_key("TakeProfit"), "{out:?}"); + let off = own(&[("UseTrailing", "YES")]); + let p = point(&[("UseTakeProfit", "YES")]); + let out = deps.complete(&p, &[&off], &HashMap::new(), &HashMap::new()); + assert_eq!(out.get("TakeProfit").map(String::as_str), Some("1")); +} + +/// Every field a number knob's rule reads has a fallback: `effective` fills only the listed +/// conditions, and a condition left absent does not block (`FieldDeps::field_active`) — the +/// strategy as it stands would read as having the field in effect behind a switch that is off, +/// and a switch the variant turns on would bring nothing (`MShotSellAtLastPrice` and its +/// `MShotSellPriceAdjust`). +#[test] +fn every_condition_of_a_number_knob_has_a_fallback() { + let rules = FieldDeps::bundled(); + let mut missing: Vec = Vec::new(); + for knob in TICK_PARAMS + .iter() + .filter(|f| matches!(f.kind, ParamKind::Num { .. })) + { + for condition in rules.conditions_of(knob.key) { + let known = CONDITION_FALLBACKS.iter().any(|(k, _)| *k == condition); + if !known && !missing.iter().any(|m| m == condition) { + missing.push(condition.to_string()); + } + } + } + assert!(missing.is_empty(), "no fallback for {missing:?}"); +} + +/// `MShotSellAtLastPrice` left out is off (the model's own default), so its adjustment is not in +/// effect as the strategy stands: a variant that switches it on brings the adjustment. +#[test] +fn a_sell_at_last_price_switched_on_brings_its_adjustment() { + let fields = [field("MShotSellPriceAdjust")]; + let start: HashMap<&'static str, usize> = [("MShotSellPriceAdjust", 0)].into_iter().collect(); + let deps = Dependents::new(&fields, &start); + let bare = own(&[]); + let out = deps.complete(&point(&[]), &[&bare], &HashMap::new(), &HashMap::new()); + assert!(out.is_empty(), "{out:?}"); + let held = own(&[("MShotSellAtLastPrice", "YES")]); + let out = deps.complete(&point(&[]), &[&bare], &held, &HashMap::new()); + assert!(out.contains_key("MShotSellPriceAdjust"), "{out:?}"); +} diff --git a/crates/moon-core/src/db/tuner/ticks/unmodelled.rs b/crates/moon-core/src/db/tuner/ticks/unmodelled.rs index 56436b37..7143a53f 100644 --- a/crates/moon-core/src/db/tuner/ticks/unmodelled.rs +++ b/crates/moon-core/src/db/tuner/ticks/unmodelled.rs @@ -28,11 +28,20 @@ //! (`assets/param_deps.toml`, [`FieldDeps`]) and its value differs from the core's default — the //! live schema's, else the one written here, which is the site's (`moonbot.pro`, the Sell order //! and Stops tabs) or, where the site is silent, the value the live strategies leave out (24.09). +//! +//! The same reading decides which rows the tuner's grid draws ([`fields_in_use`]): a field no +//! strategy of the scope switches on is left out of it. There the whole kind's schema fills what +//! a dump leaves out, as the Strategies window fills its values (`strategies::logic:: +//! selected_values`). The Delta Modifiers tab is read as a whole on top of that: its sum acts +//! only through a modifier that applies it, so a coefficient with no modifier, or a modifier with +//! no coefficient, uses nothing (the core developer via LinKvo, 2026-09-24, +//! `STRATEGY_FORMULAS/sell-common.md`). use std::collections::HashMap; use super::exit::UnmodelledRule; use super::params::ParamSection; +use crate::feed::SchemaSection; use crate::feed::strategy_deps::{FieldDeps, Values, as_bool}; /// A further condition a watched field only acts under, beyond its dependency rule. @@ -109,6 +118,16 @@ const WATCHED: &[Watched] = &[ }, ]; +/// The Delta Modifiers tab's fields that APPLY its sum `Σ Pn·Dn`, lowercase — the sell's, the +/// stop's, and the buy's and the detect's for the kinds whose schema shows them; every other field +/// of the tab but `MaxModifier` is a term of the sum. +const DELTA_APPLIERS: &[&str] = &[ + "sellmodifier", + "stoplossmodifier", + "buymodifier", + "detectmodifier", +]; + /// The fields the dependency rules of [`WATCHED`] and its extra conditions read, spelled as the /// strategy dump spells them — a read by key is case-sensitive, and [`FieldDeps`] hands the /// names back lowercase. A unit test holds this against the bundled rules. @@ -182,8 +201,7 @@ pub fn unmodelled_fields( .get(&w.key.to_ascii_lowercase()) .map(f64::to_string) .unwrap_or_else(|| w.default.to_string()); - let on = !same_value(value, &default) - && deps.field_active(w.key, &effective) + let on = switched_on(w.key, value, &default, &effective, deps) && also_holds(w.also, values, defaults); on.then(|| UnmodelledField { key: w.key, @@ -195,6 +213,90 @@ pub fn unmodelled_fields( .collect() } +/// The fields of `sections` — one strategy kind's schema — that the strategy holding `values` +/// switches on, lowercase, in the schema's order: the field's rule holds and its value is not +/// the schema's default. +/// +/// Args: +/// sections: The strategy kind's live schema, every section of it: a rule may read a field +/// of any section. +/// values: The strategy's values by field name, as `strategy_current_values` reads them — a +/// field left at its default is absent, and reads the schema's default here, else +/// nothing, as the Strategies window reads it. +/// deps: The fields' dependency rules. +pub fn fields_in_use( + sections: &[SchemaSection], + values: &HashMap, + deps: &FieldDeps, +) -> Vec { + let fields = || sections.iter().flat_map(|s| s.fields.iter()); + let mut effective: Values = values + .iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v.clone())) + .collect(); + for field in fields() { + effective + .entry(field.name.to_ascii_lowercase()) + .or_insert_with(|| field.default.clone().unwrap_or_default()); + } + let mut out: Vec = Vec::new(); + for field in fields() { + let key = field.name.to_ascii_lowercase(); + let default = field.default.as_deref().unwrap_or_default(); + let on = effective + .get(&key) + .is_some_and(|value| switched_on(&field.name, value, default, &effective, deps)); + if on && !out.contains(&key) { + out.push(key); + } + } + drop_silent_delta_tab(sections, &mut out); + out +} + +/// Take the Delta Modifiers tab out of `in_use` unless it acts: a term of the sum in use AND a +/// modifier that applies it in use. The tab is the section holding `SellModifier`. +fn drop_silent_delta_tab(sections: &[SchemaSection], in_use: &mut Vec) { + let Some(tab) = sections.iter().find(|s| { + s.fields + .iter() + .any(|f| f.name.eq_ignore_ascii_case("SellModifier")) + }) else { + return; + }; + let tab: Vec = tab + .fields + .iter() + .map(|f| f.name.to_ascii_lowercase()) + .collect(); + let applied = in_use.iter().any(|k| DELTA_APPLIERS.contains(&k.as_str())); + let summed = in_use + .iter() + .any(|k| tab.contains(k) && !DELTA_APPLIERS.contains(&k.as_str()) && k != "maxmodifier"); + if !(applied && summed) { + in_use.retain(|k| !tab.contains(k)); + } +} + +/// Whether a strategy switches a field on, as the Strategies window reads it: the field's rule +/// holds on the strategy's values and its value is not the default. +/// +/// Args: +/// key: The field. +/// value: The strategy's value of it. +/// default: The core's default of it. +/// effective: The strategy's values as the rules read them, lowercase. +/// deps: The fields' dependency rules. +fn switched_on( + key: &str, + value: &str, + default: &str, + effective: &Values, + deps: &FieldDeps, +) -> bool { + !same_value(value, default) && deps.field_active(key, effective) +} + /// Whether the extra condition of a watched field holds. fn also_holds( also: Also, diff --git a/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs b/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs index ca8330d8..75395ed3 100644 --- a/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs @@ -219,3 +219,113 @@ fn every_watched_field_sits_in_its_section() { ); } } + +/// A schema section of `(name, default)` fields. +fn schema(title: &str, fields: &[(&str, Option<&str>)]) -> crate::feed::SchemaSection { + crate::feed::SchemaSection { + title: title.to_string(), + fields: fields + .iter() + .map(|(name, default)| crate::feed::SchemaField { + name: (*name).to_string(), + type_name: "Double".to_string(), + ui: crate::feed::SchemaFieldUi::Edit, + picklist: Vec::new(), + default: default.map(str::to_string), + }) + .collect(), + } +} + +/// The grid's reading: a field is in use when its rule holds and it is off its default. A dump +/// that leaves SellSpread's switch out keeps the section off (`IgnoreSellSpread` defaults to +/// YES), so its distance, though stored, is not in use; the stop's per cent under a stop left on +/// at default is. +#[test] +fn a_field_is_in_use_when_its_rule_holds_and_it_is_off_its_default() { + let kind = [ + schema( + "Sell order", + &[ + ("HODLmode", Some("NO")), + ("AutoSell", Some("YES")), + ("SellPrice", Some("1")), + ("SellDelay", Some("0")), + ], + ), + schema( + "Sell order\\SellSpread", + &[ + ("IgnoreSellSpread", Some("YES")), + ("SellSpreadDistance", Some("0.5")), + ], + ), + schema( + "Stops", + &[ + ("UseStopLoss", Some("YES")), + ("StopLoss", Some("-5")), + ("UseSecondStop", Some("NO")), + ("SecondStopLoss", None), + ], + ), + ]; + let v = values(&[ + ("SellPrice", "1.5"), + ("SellDelay", "0.0"), + ("SellSpreadDistance", "0.8"), + ("StopLoss", "-3"), + ("SecondStopLoss", "-1"), + ]); + let used = fields_in_use(&kind, &v, &FieldDeps::bundled()); + assert_eq!(used, ["sellprice", "stoploss"]); + // Switched on, SellSpread's switch and its distance are both in use; the second stop's per + // cent stays out behind its switch, which is off by default. + let mut on = v.clone(); + on.insert("IgnoreSellSpread".into(), "NO".into()); + let used = fields_in_use(&kind, &on, &FieldDeps::bundled()); + assert_eq!( + used, + [ + "sellprice", + "ignoresellspread", + "sellspreaddistance", + "stoploss" + ] + ); + // A stop switched off takes its per cent out with it. + on.insert("UseStopLoss".into(), "NO".into()); + let used = fields_in_use(&kind, &on, &FieldDeps::bundled()); + assert!(used.contains(&"usestoploss".to_string()), "{used:?}"); + assert!(!used.contains(&"stoploss".to_string()), "{used:?}"); +} + +/// The Delta Modifiers tab acts as a whole: `AddPriceBug` with no modifier applying the sum uses +/// nothing, and neither does a modifier with no term; the two together use the tab. +#[test] +fn the_delta_tab_is_in_use_only_when_a_modifier_applies_a_term() { + let kind = [schema( + "Delta Modifiers", + &[ + ("SellModifier", Some("0")), + ("StopLossModifier", Some("0")), + ("MaxModifier", Some("0")), + ("AddPriceBug", Some("0")), + ("Add1minDelta", Some("0")), + ], + )]; + let deps = FieldDeps::bundled(); + let term = values(&[("AddPriceBug", "0.2"), ("MaxModifier", "5")]); + assert!(fields_in_use(&kind, &term, &deps).is_empty()); + let modifier = values(&[("SellModifier", "0.3")]); + assert!(fields_in_use(&kind, &modifier, &deps).is_empty()); + let both = values(&[ + ("AddPriceBug", "0.2"), + ("SellModifier", "0.3"), + ("MaxModifier", "5"), + ]); + assert_eq!( + fields_in_use(&kind, &both, &deps), + ["sellmodifier", "maxmodifier", "addpricebug"] + ); +} diff --git a/crates/moon-ui-gpui/src/analytics/mod.rs b/crates/moon-ui-gpui/src/analytics/mod.rs index 1938fd3d..c4ac9c13 100644 --- a/crates/moon-ui-gpui/src/analytics/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/mod.rs @@ -882,6 +882,7 @@ impl AnalyticsView { if let Some(saved) = backend.read(cx).layout.analytics_ticks.as_ref() { ticks.restore(saved); tuner::ticks::model_cfg::replace(saved.model); + tuner::ticks::tail::replace(saved.min_tail_s); } // Strategy-list sort is process-persistent. Unknown keys return to the same // profit-descending default used before this preference existed. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs index b9de8d65..99888720 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs @@ -82,7 +82,10 @@ impl AnalyticsView { // changed nothing can be seen to have changed nothing. SuggState::Idle => match (&self.ticks.sugg_note, &self.ticks.last_result) { (Some(note), _) => (note.clone(), p.amber), - (None, Some(result)) => (search_stats_line(&result.stats), p.text_muted), + (None, Some(result)) => ( + super::variants::search_stats_line(&result.stats), + p.text_muted, + ), (None, None) => (String::new(), p.text_muted), }, }; @@ -96,10 +99,7 @@ impl AnalyticsView { cx, ); let settings = self.ticks_search_settings(p, window, cx); - let one_tip = match self.ticks.sel_field { - Some(key) => t!("analytics.ticks.suggest_one_tip", field = key).to_string(), - None => t!("analytics.ticks.suggest_one_none").to_string(), - }; + let (one_tip, all_tip) = self.ticks_search_tips(); let controls = h_flex() .w_full() .flex_none() @@ -158,14 +158,20 @@ impl AnalyticsView { ), ) .child( - div().flex_none().child( - MoonButton::new("tun-suggest-run-x") - .variant(MoonButtonVariant::Blue) - .label(t!("analytics.tuner.suggest_run").to_string()) - .disabled(running) - .on_click(cx.listener(|this, _, window, cx| this.ticks_suggest(window, cx))) - .render(), - ), + div() + .id("tun-suggest-run-x-box") + .flex_none() + .tooltip(move |_w, cx| cx.new(|_| MoonTooltipView::new(all_tip.clone())).into()) + .child( + MoonButton::new("tun-suggest-run-x") + .variant(MoonButtonVariant::Blue) + .label(t!("analytics.tuner.suggest_run").to_string()) + .disabled(running) + .on_click( + cx.listener(|this, _, window, cx| this.ticks_suggest(window, cx)), + ) + .render(), + ), ); v_flex() .w_full() @@ -544,6 +550,7 @@ impl AnalyticsView { )); } content + .children(self.ticks_tail_rows(p, window, cx)) .child( h_flex().w_full().justify_end().child( MoonButton::new("an-ticks-model-reset") @@ -687,26 +694,6 @@ impl AnalyticsView { } } -/// The status band's account of the last search: restarts, the winning one, its passes and -/// whether it converged, how many distinct end points, how many points were scored. -fn search_stats_line(stats: &moon_core::db::tuner::ticks::SearchStats) -> String { - let passes = if stats.converged { - t!("analytics.ticks.stats_converged", n = stats.passes) - } else { - t!("analytics.ticks.stats_cut", n = stats.passes) - }; - t!( - "analytics.ticks.stats_line", - restarts = stats.restarts, - best = stats.best_restart, - passes = passes, - distinct = stats.distinct, - evals = stats.evaluations, - refused = stats.refused - ) - .to_string() -} - /// The popovers' scroll-bounded column. No padding, background, border or corners: the popover /// supplies them (see the filter's settings popover). fn popup_frame(id: &'static str, window: &Window, cx: &Context) -> Stateful
{ @@ -756,7 +743,11 @@ fn popup_head( } /// A section heading inside a popover. -fn popup_section(title: String, p: MoonPalette, cx: &Context) -> AnyElement { +pub(super) fn popup_section( + title: String, + p: MoonPalette, + cx: &Context, +) -> AnyElement { div() .w_full() .pt(design::ui_px(cx, 5.0)) @@ -767,7 +758,7 @@ fn popup_section(title: String, p: MoonPalette, cx: &Context) -> } /// One setting: its caption (with a tooltip when it needs explaining) and its control. -fn popup_row( +pub(super) fn popup_row( caption: String, tip: Option, body: AnyElement, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index 3632c26d..c85a17b8 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -4,12 +4,12 @@ //! a click sends to В1, and the two variant columns with the copy arrows and the clear crosses. //! //! The rows come by the strategy editor's sections (`sections.rs`) — Strategy settings, Stops, -//! Sell order, SellShot, SellSpread, Delta Modifiers — each with every field it holds for the -//! scope's kinds, and a tick in its heading that admits all its knobs at once. Only the knobs -//! are live; a field the model reads but does not turn, or does not know at all, is drawn -//! greyed with the strategies' value, so the grid shows the whole section as Moonbot does. The -//! sections the model does not have at all (SellShot, SellSpread) are drawn muted, every row -//! inactive, and their heading says so. +//! Sell order, SellShot, SellSpread, Delta Modifiers — each with every knob and every field a +//! strategy of the scope switches on, and a tick in its heading that admits all its knobs at +//! once. Only the knobs are live; a field the model reads but does not turn, or does not know at +//! all, is drawn greyed with the strategies' value, where Moonbot shows it. The sections the +//! model does not have at all (SellShot, SellSpread) are drawn muted, every row inactive, and +//! their heading says so; a section no row is left in is not drawn. //! //! The search still gates by group, Entry and Exit: a group the model does not reproduce well //! enough (the share gate of the search settings) is not searched, and the first section holding @@ -77,7 +77,7 @@ impl AnalyticsView { // Before the first load there is no layout; the bare headings stand in for it. let sections: Arc<[GridSection]> = match data.as_ref() { Some(d) if !d.grid.is_empty() => d.grid.clone(), - _ => layout(&[], &[]).into(), + _ => layout(&[], &[], &Default::default()).into(), }; let live: Vec<&'static str> = sections .iter() @@ -89,8 +89,9 @@ impl AnalyticsView { .w_full() .flex_none() .child(self.ticks_grid_header(live, p, cx)); - // A section the scope's kinds leave empty is dropped once the scope is known; before - // that every heading stands, the first carrying the scope's note. + // A section left without a row — no knob of the scope's kinds, no field a strategy of it + // switches on — is dropped once the scope is known; before that every heading stands, + // the first carrying the scope's note. let scoped = data.as_ref().is_some_and(|d| !d.kinds.is_empty()); let mut noted: Vec = Vec::new(); let mut first = true; @@ -146,7 +147,8 @@ impl AnalyticsView { } /// Tick or untick every field the grid shows — the header's tick. Unticked is held at its - /// base value by the search. + /// base value by the search, but for a value a switch the variant turns on needs + /// (`search::deps`). fn ticks_set_all(&mut self, fields: &[&'static str], on: bool, cx: &mut Context) { for key in fields { if on { @@ -766,6 +768,6 @@ fn knob_live(knob: &TickParam, entry_on: bool) -> bool { /// Whether a section's or the header's tick counts and toggles a knob: a live one the entry /// method reads. A field it does not read is drawn unticked whatever `locked` says, and counting /// it would leave the tick half-set with every visible box ticked. -fn knob_ticks(knob: &TickParam, entry_on: bool) -> bool { +pub(super) fn knob_ticks(knob: &TickParam, entry_on: bool) -> bool { knob_live(knob, entry_on) && super::model_cfg::current().entry_method.reads(knob.key) } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 328fd01f..7809c60b 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -48,13 +48,16 @@ use moon_core::market::trade_replay::{ /// stalls — past it the rows still unanswered fold as missing, and the log says how many. const HELD_ANSWER_WAIT: Duration = Duration::from_secs(240); -/// What stage A brings back: the deals, the grid's "now" values, the strategies' own values and -/// the exit fields outside the model they switch on. +/// What stage A brings back: the deals, the grid's "now" values, the strategies' own values, the +/// exit fields outside the model they switch on, the selected strategies' values and the rules +/// all of it was read under — the grid chooses its rows by them. type StageA = ( Result, HashMap, OwnValues, Arc, + Vec, + FieldDeps, ); /// What stage B publishes beside the rows: the strategies' values and the grid's layout. @@ -171,18 +174,19 @@ impl AnalyticsView { .map(|read| own_values(&read.deals, &keys)) .unwrap_or_default(); let (now, selected) = now_values(&targets, &keys, &own); - // The rules are read per load: an edit of the file reaches the warning on the - // axis' next load, never a process-lifetime copy. + // The rules are read per load: an edit of the file reaches the warning and the + // grid on the axis' next load, never a process-lifetime copy. + let deps = FieldDeps::load(); let unmodelled = unmodelled_map( own.iter() .map(|(&(sid, core), values)| ((sid, Some(core)), values.as_ref())) .chain(selected.iter().map(|(key, values)| (*key, values.as_ref()))), &defaults, - &FieldDeps::load(), + &deps, ); - (deals, now, own, Arc::new(unmodelled)) + (deals, now, own, Arc::new(unmodelled), selected, deps) }, - move |this, (deals, now, own, unmodelled): StageA, cx| { + move |this, (deals, now, own, unmodelled, selected, deps): StageA, cx| { if this.ticks.seq != req { return; } @@ -204,7 +208,15 @@ impl AnalyticsView { return; } }; - let grid = this.grid_layout(&read.deals, cx); + let grid = super::sections::grid_for( + this.backend.read(cx).session.store(), + &read.deals, + own.iter() + .map(|(&(sid, core), values)| ((sid, Some(core)), values.as_ref())) + .chain(selected.iter().map(|(key, values)| (*key, values.as_ref()))), + &deps, + ) + .into(); let addresses = this.resolve_addresses(&read.deals, cx); this.start_replay_stage( req, @@ -224,28 +236,6 @@ impl AnalyticsView { ); } - /// The grid's rows for the deals' kinds, by section: the fields the live schema files under - /// each of the kinds, the knobs no schema places under their own section. - fn grid_layout( - &self, - deals: &[Deal], - cx: &Context, - ) -> Arc<[super::sections::GridSection]> { - let mut kinds: Vec = Vec::new(); - for deal in deals { - if !kinds.contains(&deal.kind) { - kinds.push(deal.kind.clone()); - } - } - let knobs = super::sections::scope_knobs(&kinds); - let backend = self.backend.read(cx); - let schema = super::sections::scope_schema( - backend.session.store(), - deals.iter().map(|d| (d.strategy_id, d.core_uid)), - ); - super::sections::layout(&schema, &knobs).into() - } - /// Where each deal's prints live, per distinct `(core, coin)` — the fetch's own resolver, /// asked once per distinct pair. A core that is not connected, or a coin its catalog does /// not spell, resolves to nothing and the row says so. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index fe2bf689..d6f0f09c 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -46,6 +46,7 @@ pub(in crate::analytics) mod model_cfg; pub(in crate::analytics::tuner) mod rows; mod sections; pub(in crate::analytics) mod state; +pub(in crate::analytics) mod tail; mod tape; mod trade_pane; mod unmodelled; @@ -216,7 +217,8 @@ impl AnalyticsView { // out of how many — with what the scope holds beyond the table, and the switch that // hides the rest. How well the MODEL does on that sample is the KPI caption's ✓ // shares (`ticks_kpi`), not a size. - let status = coverage_caption(covered, fit, total, without_ms, left_out.1, left_out.2); + let status = coverage_caption(covered, fit, total, without_ms, left_out.1, left_out.2) + + &self.ticks_short_tail_note(); // How many rows read live deltas, and — in the tooltip — how well each one's history // reproduces the core (`delta_summary`). let deltas = self diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index c61a7e48..6d8f06f0 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -402,7 +402,7 @@ fn the_cap_keeps_only_the_fit_rows_tapes_and_counts_them_packed() { address: None, ticks: Some(tape_of(10)), entry_line: None, - held: None, + held: Some((60_000, 60_000)), }, DealRow { deal: deal(2, 2_000, 100.0, 99.0, false), @@ -442,7 +442,7 @@ fn a_fit_row_without_its_tape_is_the_one_that_lost_it() { address: None, ticks: Some(tape_of(3)), entry_line: None, - held: None, + held: Some((60_000, 60_000)), }; assert!(!row.lost_tape()); row.ticks = None; @@ -450,3 +450,22 @@ fn a_fit_row_without_its_tape_is_the_one_that_lost_it() { row.verdict = Some(verdict(Some(false), Some(true))); assert!(!row.lost_tape(), "an unfit row keeps no tape by design"); } + +/// A reproduced row whose tape stops short of the shortest tail past the close is out of the +/// sample: nothing held past the close against the setting in force, whatever the store's +/// margin caps it at, while two hours reach any of them. +#[test] +fn a_reproduced_row_with_a_short_tail_is_not_fit() { + let mut row = DealRow { + deal: deal(1, 1_000, 100.0, 101.0, false), + tape: TapeStatus::Covered, + verdict: Some(verdict(Some(true), Some(true))), + address: None, + ticks: Some(tape_of(3)), + entry_line: None, + held: Some((60_000, 0)), + }; + assert!(!row.fit()); + row.held = Some((60_000, 7_200_000)); + assert!(row.fit()); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs index fd2770e5..7f4f4376 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs @@ -1,10 +1,13 @@ //! The rows of the Entry/Exit grid, laid out by the strategy editor's sections — Strategy -//! settings, Stops, Sell order, SellShot, SellSpread, Delta Modifiers — with EVERY field each +//! settings, Stops, Sell order, SellShot, SellSpread, Delta Modifiers — with the fields each //! section holds for the scope's kinds, as the Strategies window shows them. Only the knobs of -//! [`TICK_PARAMS`](moon_core::db::tuner::ticks::TICK_PARAMS) are searched; every other field is -//! drawn fixed, so what the model does not turn yet stays in sight where the user looks for it. -//! The sections the model does not have at all — SellShot and SellSpread -//! ([`ParamSection::modelled`]) — keep their fields in sight too, every one of them inactive. +//! [`TICK_PARAMS`](moon_core::db::tuner::ticks::TICK_PARAMS) are searched, and they are always +//! drawn; every other field is drawn fixed, and only while a strategy of the scope switches it +//! on (`unmodelled::fields_in_use`: its rule holds and it is off its default), so what the model +//! does not turn yet stays in sight where the user looks for it, and what no strategy uses does +//! not crowd it. The sections the model does not have at all — SellShot and SellSpread +//! ([`ParamSection::modelled`]) — follow the same rule, every row of them inactive; a section +//! left without a row is not drawn. //! //! The field lists come from the live schema of each deal's strategy kind — the store's strategy //! row gives the kind ordinal, as `strategies::logic::selected_sections` does; the `SignalType` @@ -14,8 +17,11 @@ use std::collections::{HashMap, HashSet}; +use moon_core::db::tuner::ticks::Deal; use moon_core::db::tuner::ticks::params::{ParamSection, TickParam, is_model_only, params_for}; +use moon_core::db::tuner::ticks::unmodelled::fields_in_use; use moon_core::feed::SchemaSection; +use moon_core::feed::strategy_deps::FieldDeps; use moon_core::session::CoreStore; use crate::strategies::sections::section_title_eq; @@ -76,18 +82,62 @@ pub(in crate::analytics::tuner) fn scope_knobs(kinds: &[String]) -> Vec<&'static .collect() } +/// The grid's rows for the deals' kinds, by section: the fields the live schema files under +/// each of the kinds that a strategy of the scope switches on, every knob, the knobs no schema +/// places under their own section. +/// +/// Args: +/// store: The connected cores, for their schemas and strategy lists. +/// deals: The scope's deals. +/// strategies: Every strategy of the scope with its values, by `(strategy_id, core_uid)` — +/// the deals' own and the selected ones. One whose kind the store cannot tell — no core +/// given, or its core no longer lists it (renamed or deleted since the trade) — is read +/// against every kind of the scope, so a field it switches on is not lost. +/// deps: The fields' dependency rules, read for this load. +pub(in crate::analytics::tuner) fn grid_for<'a>( + store: &CoreStore, + deals: &[Deal], + strategies: impl IntoIterator), &'a HashMap)>, + deps: &FieldDeps, +) -> Vec { + let mut kinds: Vec = Vec::new(); + for deal in deals { + if !kinds.contains(&deal.kind) { + kinds.push(deal.kind.clone()); + } + } + let knobs = scope_knobs(&kinds); + let schema = scope_schema(store, deals.iter().map(|d| (d.strategy_id, d.core_uid))); + let mut in_use: HashSet = HashSet::new(); + for ((sid, core), values) in strategies { + match core.and_then(|core| strategy_schema(store, sid, core)) { + Some(sections) => in_use.extend(fields_in_use(sections, values, deps)), + None => { + for sections in &schema { + in_use.extend(fields_in_use(sections, values, deps)); + } + } + } + } + layout(&schema, &knobs, &in_use) +} + /// The grid's sections, every one of [`ParamSection::GRID_ORDER`] in that order, empty ones -/// included. +/// included — the grid drops those once the scope is known. /// /// A field goes where the first kind's schema that has it files it; a field two sections share -/// is drawn once. A knob no schema places goes under its own [`TickParam::section`]. +/// is drawn once. A knob no schema places goes under its own [`TickParam::section`]. A field +/// that is not a knob is drawn only when `in_use` holds it. /// /// Args: /// kind_sections: The schema sections of each kind in the scope. /// knobs: The knobs of the scope ([`scope_knobs`]). +/// in_use: The fields some strategy of the scope switches on, lowercase +/// (`unmodelled::fields_in_use`). pub(in crate::analytics::tuner) fn layout( kind_sections: &[&[SchemaSection]], knobs: &[&'static TickParam], + in_use: &HashSet, ) -> Vec { let mut seen: HashSet = HashSet::new(); let mut out: Vec = ParamSection::GRID_ORDER @@ -105,8 +155,13 @@ pub(in crate::analytics::tuner) fn layout( .filter(|s| section_title_eq(&s.title, title)) { for field in §ion.fields { - if seen.insert(field.name.to_ascii_lowercase()) { - grid.rows.push(row(&field.name, grid.section, knobs)); + let key = field.name.to_ascii_lowercase(); + if !seen.insert(key.clone()) { + continue; + } + let row = row(&field.name, grid.section, knobs); + if matches!(row.role, RowRole::Knob(_)) || in_use.contains(&key) { + grid.rows.push(row); } } } @@ -176,8 +231,26 @@ pub(in crate::analytics::tuner) fn scope_schema( out } -/// Every field name the grid's sections hold in any kind of any connected core's schema — the -/// keys the "now" column reads beside the models' own, so a fixed row shows the strategy's value. +/// The schema sections of one strategy's kind, when its core is connected with a schema and +/// still lists the strategy. +fn strategy_schema(store: &CoreStore, strategy: i64, core: u64) -> Option<&[SchemaSection]> { + let data = store.core(core)?; + let schema = data.schema.as_ref()?; + let row = data + .strategies + .iter() + .find(|row| same_strategy(row.id, strategy))?; + schema + .kinds + .iter() + .find(|k| k.ordinal == row.kind_ordinal) + .map(|k| k.sections.as_slice()) +} + +/// Every field name any kind of any connected core's schema holds — the keys the "now" column +/// reads beside the models' own, so a fixed row shows the strategy's value, and the ones the +/// grid's rows are chosen by ([`grid_for`]): a field's rule may read a field of a section the grid +/// does not draw (`HODLmode`), and one left unread would stand at its default. pub(in crate::analytics::tuner) fn schema_keys(store: &CoreStore) -> Vec { let mut seen: HashSet<&str> = HashSet::new(); for (_, core) in store.cores() { @@ -185,11 +258,7 @@ pub(in crate::analytics::tuner) fn schema_keys(store: &CoreStore) -> Vec continue; }; for kind in &schema.kinds { - for section in kind.sections.iter().filter(|s| { - ParamSection::GRID_ORDER - .iter() - .any(|g| section_title_eq(&s.title, g.schema_title())) - }) { + for section in &kind.sections { seen.extend(section.fields.iter().map(|f| f.name.as_str())); } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs index 8e7eede3..70bc6ff0 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs @@ -25,9 +25,23 @@ fn find(out: &[GridSection], s: ParamSection) -> &GridSection { out.iter().find(|g| g.section == s).expect("section") } +/// Every field of `kinds` in use, lowercase: the layout as it stood before rows were chosen. +fn all_used(kinds: &[&[SchemaSection]]) -> HashSet { + kinds + .iter() + .flat_map(|k| k.iter()) + .flat_map(|s| s.fields.iter()) + .map(|f| f.name.to_ascii_lowercase()) + .collect() +} + +fn used(names: &[&str]) -> HashSet { + names.iter().map(|n| n.to_ascii_lowercase()).collect() +} + #[test] fn every_section_comes_out_in_grid_order() { - let out = layout(&[], &[]); + let out = layout(&[], &[], &HashSet::new()); let order: Vec = out.iter().map(|g| g.section).collect(); assert_eq!(order, ParamSection::GRID_ORDER); assert!(out.iter().all(|g| g.rows.is_empty())); @@ -62,7 +76,7 @@ fn the_schema_places_every_field_and_marks_what_the_model_turns() { ), section("Filters", &["MinVolume"]), ]; - let out = layout(&[kind.as_slice()], &knobs); + let out = layout(&[kind.as_slice()], &knobs, &all_used(&[kind.as_slice()])); let settings = find(&out, ParamSection::StrategySettings); assert_eq!( @@ -118,7 +132,7 @@ fn the_schema_places_every_field_and_marks_what_the_model_turns() { #[test] fn a_knob_the_schema_does_not_place_goes_under_its_own_section_once() { let knobs = scope_knobs(&["MoonShot".to_string()]); - let out = layout(&[], &knobs); + let out = layout(&[], &knobs, &HashSet::new()); for knob in &knobs { let at: Vec = out .iter() @@ -129,7 +143,7 @@ fn a_knob_the_schema_does_not_place_goes_under_its_own_section_once() { } // With the schema, the field keeps the schema's place and is not drawn a second time. let kind = vec![section("Sell order", &["SellPrice"])]; - let out = layout(&[kind.as_slice()], &knobs); + let out = layout(&[kind.as_slice()], &knobs, &HashSet::new()); let placed: usize = out .iter() .map(|g| g.rows.iter().filter(|r| r.key == "SellPrice").count()) @@ -142,7 +156,8 @@ fn two_kinds_share_a_section_without_repeating_a_field() { let knobs = scope_knobs(&["MoonShot".to_string(), "MoonHook".to_string()]); let shot = vec![section("Stops", &["UseStopLoss", "StopLoss"])]; let hook = vec![section("Stops", &["StopLoss", "StopLossDelay"])]; - let out = layout(&[shot.as_slice(), hook.as_slice()], &knobs); + let kinds = [shot.as_slice(), hook.as_slice()]; + let out = layout(&kinds, &knobs, &all_used(&kinds)); let stops = keys(find(&out, ParamSection::Stops)); // The two kinds' fields first, each once; the section's knobs no schema here places follow. assert_eq!(stops[..3], ["UseStopLoss", "StopLoss", "StopLossDelay"]); @@ -157,12 +172,77 @@ fn a_knob_another_kind_has_is_outside_for_a_kind_that_does_not_read_it() { // A MoonHook's take is `HookSellLevel`; `SellPrice` moves nothing for it. let knobs = scope_knobs(&["MoonHook".to_string()]); let kind = vec![section("Sell order", &["SellPrice", "SellDelay"])]; - let out = layout(&[kind.as_slice()], &knobs); + let out = layout(&[kind.as_slice()], &knobs, &used(&["SellPrice"])); let sell = find(&out, ParamSection::SellOrder); assert_eq!(sell.rows[0].role, RowRole::Outside); assert!(matches!(sell.rows[1].role, RowRole::Knob(p) if p.key == "SellDelay")); } +/// Only the knobs and the fields a strategy of the scope switches on are drawn: a MoonShot scope +/// that keeps SellSpread off (`IgnoreSellSpread` at its default YES) and never touched +/// `TrailingSpread` shows neither — the SellSpread section is left with no row — while every knob +/// stands, used or not, and a field outside the model in use keeps its place in the schema's +/// order. +#[test] +fn only_knobs_and_the_fields_in_use_are_drawn() { + let knobs = scope_knobs(&["MoonShot".to_string()]); + let kind = vec![ + section("Strategy settings", &["MShotPrice", "MShotRepeatWait"]), + section( + "Sell order\\SellSpread", + &["IgnoreSellSpread", "SellSpreadDistance"], + ), + section( + "Stops", + &[ + "UseStopLoss", + "StopLoss", + "DontSellBelowLiq", + "TrailingSpread", + ], + ), + ]; + let out = layout( + &[kind.as_slice()], + &knobs, + &used(&["MShotRepeatWait", "DontSellBelowLiq"]), + ); + let settings = find(&out, ParamSection::StrategySettings); + assert_eq!(keys(settings)[..2], ["MShotPrice", "MShotRepeatWait"]); + assert_eq!(settings.rows[1].role, RowRole::Outside); + assert!(find(&out, ParamSection::SellSpread).rows.is_empty()); + let stops = keys(find(&out, ParamSection::Stops)); + assert_eq!(stops[..3], ["UseStopLoss", "StopLoss", "DontSellBelowLiq"]); + assert!(!stops.contains(&"TrailingSpread"), "{stops:?}"); + // Every knob of the scope is drawn once, whether a strategy moved it or not. + for knob in &knobs { + let n: usize = out + .iter() + .map(|g| g.rows.iter().filter(|r| r.key == knob.key).count()) + .sum(); + assert_eq!(n, 1, "{}", knob.key); + } +} + +/// SellSpread switched on in one strategy of the scope brings its section back, inactive, with +/// the fields that strategy uses. +#[test] +fn a_section_the_model_lacks_is_drawn_while_a_strategy_uses_it() { + let knobs = scope_knobs(&["MoonShot".to_string()]); + let kind = vec![section( + "Sell order\\SellSpread", + &["IgnoreSellSpread", "SellSpreadDistance", "SellSpreadDelay"], + )]; + let out = layout( + &[kind.as_slice()], + &knobs, + &used(&["IgnoreSellSpread", "SellSpreadDistance"]), + ); + let spread = find(&out, ParamSection::SellSpread); + assert_eq!(keys(spread), ["IgnoreSellSpread", "SellSpreadDistance"]); + assert!(spread.rows.iter().all(|r| r.role == RowRole::Unmodelled)); +} + #[test] fn a_strategy_id_past_i64_max_is_the_reports_negative_one() { // The report stores the core's u64 id as a signed column: HookTestO1 on GateF is diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index ed3eb367..944aae34 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -74,10 +74,13 @@ pub(in crate::analytics::tuner) struct DealRow { } impl DealRow { - /// Whether the variants and the search run on this row: its tape covers the window and the - /// model reproduced it (`fit_for_search`). The table shows every row; this is the sample. + /// Whether the variants and the search run on this row: its tape covers the window, holds + /// the shortest tail past the close (`tail`) and the model reproduced it (`fit_for_search`). + /// The table shows every row; this is the sample. pub(in crate::analytics::tuner) fn fit(&self) -> bool { - self.tape == TapeStatus::Covered && self.verdict.as_ref().is_some_and(fit_for_search) + self.tape == TapeStatus::Covered + && self.verdict.as_ref().is_some_and(fit_for_search) + && super::tail::holds(self.held) } /// Whether the row is fit but holds no tape — one the memory cap let go under a wider scope. @@ -327,7 +330,7 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) var_task: Option>, /// The grid's and the row's input boxes, created lazily and kept across repaints. pub(in crate::analytics::tuner) inputs: HashMap>, - /// Fields held at their base value by the search — the grid's unticked rows. Persisted. + /// Unticked rows: held at base by the search, bar what a switch it turns on needs. Persisted. pub(in crate::analytics::tuner) locked: HashSet, /// The field "Search" on one field varies — the one whose name was clicked last. pub(in crate::analytics::tuner) sel_field: Option<&'static str>, @@ -506,6 +509,7 @@ impl TicksState { model: super::model_cfg::current(), trade_open: self.trade.open, allow_closer_corridor: !self.keep_corridor, + min_tail_s: Some(super::tail::current_s()), } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/tail.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tail.rs new file mode 100644 index 00000000..b1f2e5da --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tail.rs @@ -0,0 +1,185 @@ +//! The shortest tape past the close a deal must hold to be worked on: a row whose held trail +//! (`DealRow::held`) is shorter is not fit (`DealRow::fit`), so the fact's baseline, the variant +//! columns and the search all run on the same deals, and the sample's exit horizon — the shortest +//! trail among them (`search::common_horizon_ms`) — is never under this. +//! +//! One deal with a 30 s tail cut every variant's exit 30 s past its close: a variant that fills a +//! hair off the fact's price reaches its take or its Price Down line later than the fact did, is +//! left open at the cut, and refuses the whole point (`search::closing`). The tape of an older +//! trade cannot be fetched again — its exchange no longer serves it — so the deal is left out +//! instead (LinKvo, 2026-09-24). +//! +//! Held for the whole process like the model's settings (`model_cfg`): every path that asks +//! whether a row is fit reads it here. Seeded from the saved layout when an analytics view +//! opens; written by the model popover's "Sample" section. + +use std::sync::atomic::{AtomicU32, Ordering}; + +use gpui::*; +use moon_ui::{MoonInput, MoonInputEvent, MoonInputState, MoonPalette}; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +use super::state::TicksData; +use crate::design; + +#[cfg(test)] +mod tests; + +/// The default shortest tail, seconds: a minute (LinKvo, 2026-09-24). +const DEFAULT_MIN_TAIL_S: u32 = 60; + +/// The longest the setting may ask for — the longest margin the tape store fetches +/// (`moon_core::config::storage::MAX_TRADE_MARGIN_S`): a longer one would leave every deal out. +const MAX_MIN_TAIL_S: u32 = moon_core::config::storage::MAX_TRADE_MARGIN_S; + +/// The shortest tail in force, seconds. +static MIN_TAIL_S: AtomicU32 = AtomicU32::new(DEFAULT_MIN_TAIL_S); + +/// The input box's cache key in the axis' inputs. +const INPUT_ID: &str = "tail:min"; + +/// The shortest tail as set, seconds. +pub(in crate::analytics::tuner) fn current_s() -> u32 { + MIN_TAIL_S.load(Ordering::Relaxed) +} + +/// The shortest tail in force, seconds: the setting, but never past the tape store's margin +/// (`[trade_replay] margin`, the Storage tab) — a row holds at most that much past its close, +/// and a minimum above it would leave every deal out. +pub(in crate::analytics::tuner) fn effective_s() -> u32 { + let margin_s = moon_core::market::trade_replay::margin_ms() / 1_000; + current_s().min(u32::try_from(margin_s.max(0)).unwrap_or(u32::MAX)) +} + +/// Put a saved value in force; `None` is the default. +pub(in crate::analytics) fn replace(secs: Option) { + MIN_TAIL_S.store( + secs.unwrap_or(DEFAULT_MIN_TAIL_S).min(MAX_MIN_TAIL_S), + Ordering::Relaxed, + ); +} + +/// Whether a row's held coverage reaches far enough past the close. +/// +/// Args: +/// held: The row's `(lead_ms, trail_ms)` ([`super::state::DealRow::held`]); `None` holds +/// nothing. +pub(in crate::analytics::tuner) fn holds(held: Option<(i64, i64)>) -> bool { + reaches(held, effective_s()) +} + +/// Whether `held` reaches `min_s` seconds past the close. +fn reaches(held: Option<(i64, i64)>, min_s: u32) -> bool { + held.is_some_and(|(_, trail_ms)| trail_ms >= i64::from(min_s) * 1_000) +} + +/// A typed value in seconds: a whole number, clamped to the store's longest margin; anything +/// else is refused. +fn parse_s(text: &str) -> Option { + text.trim() + .parse::() + .ok() + .map(|s| s.min(MAX_MIN_TAIL_S)) +} + +impl TicksData { + /// Rows the model reproduces, with their tape, left out only for a tail shorter than the + /// setting — what the footer counts. + fn short_tail(&self) -> usize { + self.rows + .iter() + .filter(|r| { + r.tape == super::state::TapeStatus::Covered + && r.verdict + .as_ref() + .is_some_and(moon_core::db::tuner::ticks::fit_for_search) + && !holds(r.held) + }) + .count() + } +} + +impl AnalyticsView { + /// The footer's note on the rows the tail left out, with its leading separator; empty when + /// none. + pub(super) fn ticks_short_tail_note(&self) -> String { + match self.ticks.data.data().map(|d| d.short_tail()) { + Some(n) if n > 0 => format!( + " · {}", + t!("analytics.ticks.tail_short", n = n, s = effective_s()) + ), + _ => String::new(), + } + } + + /// The model popover's "Sample" section: its heading and the shortest tail's box. + pub(super) fn ticks_tail_rows( + &mut self, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> [AnyElement; 2] { + let input = self.ticks_tail_input(window, cx); + [ + super::cfg::popup_section(t!("analytics.ticks.model_sec_sample").to_string(), p, cx), + super::cfg::popup_row( + t!("analytics.ticks.tail_min").to_string(), + Some(t!("analytics.ticks.tail_min_tip").to_string()), + div() + .w(design::font_w_px(cx, 76.0)) + .flex_none() + .font_family(design::mono()) + .child( + MoonInput::new("an-ticks-m-tail") + .state(&input) + .size(design::INPUT_SIZE), + ) + .into_any_element(), + p, + cx, + ), + ] + } + + /// The shortest tail's box. Taken on Enter or when the box loses focus, never per keystroke: + /// each commit reloads the axis — the rows a shorter tail left out lost their tape to the + /// memory cap and are read again. A value it cannot take puts the box back. + fn ticks_tail_input( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + if let Some(state) = self.ticks.inputs.get(INPUT_ID) { + return state.clone(); + } + let state = + cx.new(|cx| MoonInputState::new(window, cx).default_value(current_s().to_string())); + cx.subscribe_in( + &state, + window, + move |this, state, ev: &MoonInputEvent, _window, cx| { + if !matches!(ev, MoonInputEvent::Blur | MoonInputEvent::PressEnter { .. }) { + return; + } + let typed = state.read(cx).value().to_string(); + if let Some(secs) = parse_s(&typed).filter(|&secs| secs != current_s()) { + MIN_TAIL_S.store(secs, Ordering::Relaxed); + this.persist_ticks_settings(cx); + this.reload_ticks(cx); + } + // A value clamped or refused: the box is recreated from the value in force on + // the next frame, so it never shows what is not applied. + if typed.trim() != current_s().to_string() { + this.ticks.inputs.remove(INPUT_ID); + } + cx.notify(); + }, + ) + .detach(); + self.ticks + .inputs + .insert(INPUT_ID.to_string(), state.clone()); + state + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/tail/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tail/tests.rs new file mode 100644 index 00000000..0cc09c95 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tail/tests.rs @@ -0,0 +1,26 @@ +//! The shortest tail a deal must hold. + +use super::{MAX_MIN_TAIL_S, parse_s, reaches}; + +/// A trail reaches the minimum at it and past it; a shorter one, or no coverage, does not; 0 +/// takes every covered row. +#[test] +fn a_trail_reaches_the_minimum_at_and_past_it() { + assert!(reaches(Some((0, 60_000)), 60)); + assert!(reaches(Some((5_000, 180_000)), 60)); + assert!(!reaches(Some((60_000, 30_000)), 60)); + assert!(!reaches(None, 60)); + assert!(reaches(Some((0, 0)), 0)); +} + +/// A typed value is whole seconds, clamped to the store's longest margin; anything else is +/// refused. +#[test] +fn a_typed_value_is_whole_seconds_clamped_to_the_store() { + assert_eq!(parse_s(" 90 "), Some(90)); + assert_eq!(parse_s("0"), Some(0)); + assert_eq!(parse_s("100000"), Some(MAX_MIN_TAIL_S)); + assert_eq!(parse_s("1.5"), None); + assert_eq!(parse_s("-5"), None); + assert_eq!(parse_s(""), None); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 52171b8b..b87e5339 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -38,6 +38,9 @@ use moon_core::db::tuner::ticks::stats_of; mod probe; pub(super) use probe::painted as probe_painted; +#[cfg(test)] +mod tests; + /// How long a burst of cell edits may keep coalescing before the columns are rescored. const VARIANT_DEBOUNCE: Duration = Duration::from_millis(350); @@ -243,6 +246,35 @@ impl AnalyticsView { supported && data.group_passes(group, self.ticks.gate()) == Some(true) } + /// The tooltips of "Search" and "Search all": what each varies and where its answer lands — + /// the selected field by name, the number of ticked fields the gate lets through. A scope of + /// several kinds, which neither searches, says so on both. + pub(super) fn ticks_search_tips(&self) -> (String, String) { + let data = self.ticks.data.data(); + if data.is_some_and(|d| !d.kinds.is_empty() && d.single_kind().is_none()) { + let refused = t!("analytics.ticks.sugg_one_kind").to_string(); + return (refused.clone(), refused); + } + let one = match self.ticks.sel_field { + Some(key) => t!("analytics.ticks.suggest_one_tip", field = key).to_string(), + None => t!("analytics.ticks.suggest_one_none").to_string(), + }; + let entry_on = data.is_some_and(|d| d.entry_modelled()); + let ticked = data.map_or(0, |d| { + d.grid + .iter() + .flat_map(super::sections::GridSection::knobs) + .filter(|k| super::grid::knob_ticks(k, entry_on)) + .filter(|k| !self.ticks.locked.contains(k.key)) + .filter(|k| self.ticks_group_searchable(k.group)) + .count() + }); + ( + one, + t!("analytics.ticks.suggest_all_tip", n = ticked).to_string(), + ) + } + /// "Search all": every ticked field of the groups the gate lets through, into В1. pub(in crate::analytics::tuner) fn ticks_suggest( &mut self, @@ -252,8 +284,8 @@ impl AnalyticsView { self.ticks_run_search(None, window, cx); } - /// "Search": the selected field alone, the rest of В1 held as it stands; the answer goes - /// into that one cell of В1. + /// "Search": the selected field alone, ticked or not, the rest of В1 held as it stands; the + /// answer goes into that cell of В1 and the cells of the values it completed ([`land_answer`]). pub(in crate::analytics::tuner) fn ticks_suggest_one( &mut self, window: &mut Window, @@ -365,7 +397,9 @@ impl AnalyticsView { let train_frac = super::super::filter::state::train_frac(self.ticks.train_pct); let keep_corridor = self.ticks.keep_corridor; // A floor over the slice the search fits on no point can keep: say so before a run that - // can only come back empty. + // can only come back empty. Counted on every replayable row: the search then drops the + // deals the strategies as they stand leave open (`closing::closable_at_base`), so a floor + // this lets through can still fail there, and the search says so itself. let closes: Vec = pending.iter().map(|d| d.deal.close_ms).collect(); let train_n = train_len(&closes, train_frac); if let Some(n) = min_n.filter(|n| *n > train_n as i64) { @@ -430,21 +464,7 @@ impl AnalyticsView { this.ticks.sugg = SuggState::Idle; match result { Ok(result) => { - match only { - None => { - this.ticks.variants[0] = - result.values.iter().cloned().collect::>(); - } - // Only the searched cell moves; one the search left at its base - // keeps what В1 had. - Some(key) => { - if let Some((_, value)) = - result.values.iter().find(|(k, _)| k == key) - { - this.ticks.set_variant(0, key, value.clone()); - } - } - } + land_answer(&mut this.ticks.variants[0], only, &result.values); this.ticks_reset_inputs_of(0); this.ticks.last_seed = Some(result.seed); this.ticks.last_result = Some(result); @@ -647,3 +667,45 @@ impl AnalyticsView { vec![t!("analytics.ticks.unguarded_warn", n = n, m = owns.len()).to_string()] } } + +/// Lay a search's answer into В1. A search of every field replaces В1 with it. A search of one +/// field lays its cells over В1's: the searched field, and every value the answer completed for a +/// switch it turned on (`search::deps` — `UseTakeProfit` brings its `TakeProfit`), which the +/// search scored and Save must write with it; the search locked every other field, so nothing +/// else moves, and a field it left at its base keeps what В1 had. +/// +/// Args: +/// v1: В1's cells. +/// only: The searched field, for a search of one. +/// values: The answer ([`SearchResult::values`](moon_core::db::tuner::ticks::search::SearchResult)). +fn land_answer(v1: &mut HashMap, only: Option<&str>, values: &[(String, String)]) { + match only { + None => *v1 = values.iter().cloned().collect(), + Some(_) => v1.extend(values.iter().cloned()), + } +} + +/// The status band's account of the last search: restarts, the winning one, its passes and +/// whether it converged, how many distinct end points, how many points were scored. +pub(super) fn search_stats_line(stats: &moon_core::db::tuner::ticks::SearchStats) -> String { + let passes = if stats.converged { + t!("analytics.ticks.stats_converged", n = stats.passes) + } else { + t!("analytics.ticks.stats_cut", n = stats.passes) + }; + let line = t!( + "analytics.ticks.stats_line", + restarts = stats.restarts, + best = stats.best_restart, + passes = passes, + distinct = stats.distinct, + evals = stats.evaluations, + refused = stats.refused + ) + .to_string(); + // The deals taken out before the search: the sample it answers for is smaller by them. + match stats.left_open { + 0 => line, + n => format!("{line} · {}", t!("analytics.ticks.stats_left_open", n = n)), + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs new file mode 100644 index 00000000..4d689b97 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs @@ -0,0 +1,57 @@ +//! Where a search's answer lands in В1. + +use std::collections::HashMap; + +use super::land_answer; + +fn cells(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +fn answer(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +/// "Search" on `UseTakeProfit`: the switch lands, and so does the `TakeProfit` the answer +/// completed for it — Save would otherwise write the switch with no per cent behind it — while +/// every other cell of В1 stays. +#[test] +fn a_search_of_one_field_lands_the_values_it_completed() { + let mut v1 = cells(&[("SellPrice", "1.5"), ("StopLoss", "-3")]); + land_answer( + &mut v1, + Some("UseTakeProfit"), + &answer(&[("TakeProfit", "1"), ("UseTakeProfit", "YES")]), + ); + assert_eq!( + v1, + cells(&[ + ("SellPrice", "1.5"), + ("StopLoss", "-3"), + ("UseTakeProfit", "YES"), + ("TakeProfit", "1"), + ]) + ); +} + +/// A search of one field that left it at its base answers without it: В1's cell stays. +#[test] +fn a_field_left_at_its_base_keeps_what_v1_had() { + let mut v1 = cells(&[("SellPrice", "1.5")]); + land_answer(&mut v1, Some("SellPrice"), &[]); + assert_eq!(v1, cells(&[("SellPrice", "1.5")])); +} + +/// "Search all" replaces В1 with its answer. +#[test] +fn a_search_of_every_field_replaces_v1() { + let mut v1 = cells(&[("SellPrice", "1.5"), ("StopLoss", "-3")]); + land_answer(&mut v1, None, &answer(&[("SellPrice", "2")])); + assert_eq!(v1, cells(&[("SellPrice", "2")])); +} diff --git a/locales/analytics.yml b/locales/analytics.yml index 802e4adb..0320b13a 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1712,6 +1712,22 @@ analytics.ticks.coverage: ru: "с лентой %{covered} из %{total} · годных %{fit} · без мс-штампа %{without}" en: "tape for %{covered} of %{total} · reproduced %{fit} · no ms stamp %{without}" es: "cinta en %{covered} de %{total} · reproducidas %{fit} · sin marca ms %{without}" +analytics.ticks.tail_short: + ru: "лента после закрытия < %{s} с: %{n}" + en: "tape past the close < %{s} s: %{n}" + es: "cinta tras el cierre < %{s} s: %{n}" +analytics.ticks.model_sec_sample: + ru: "Выборка" + en: "Sample" + es: "Muestra" +analytics.ticks.tail_min: + ru: "Лента после закрытия ≥, с" + en: "Tape past the close ≥, s" + es: "Cinta tras el cierre ≥, s" +analytics.ticks.tail_min_tip: + ru: "Сделки, у которых лента после закрытия короче, не идут ни в «Годные», ни в В1/В2, ни в подбор: при варианте они закрылись бы позже факта и остались бы открытыми на обрезе ленты. Догрузить старую ленту нельзя — биржа её уже не отдаёт. Не больше запаса подгрузки из «Хранилища»: лент длиннее у сделок не бывает. 0 — без отбора" + en: "Trades whose tape past the close is shorter are left out of the reproduced baseline, V1/V2 and the search: under a variant they would close later than the fact and stay open where the tape ends. An old tape cannot be fetched again — the exchange no longer serves it. Never more than the Storage tab's loading margin: no trade holds more. 0 — no filter" + es: "Las operaciones cuya cinta tras el cierre es más corta quedan fuera de las reproducidas, V1/V2 y la búsqueda: con una variante cerrarían más tarde que el hecho y quedarían abiertas donde termina la cinta. Una cinta antigua no se puede volver a descargar — el exchange ya no la sirve. Nunca más que el margen de carga de la pestaña Almacenamiento: ninguna operación tiene más. 0 — sin filtro" analytics.ticks.coverage_service: ru: "служебных %{n}" en: "service rows %{n}" @@ -2016,6 +2032,10 @@ analytics.ticks.sugg_none: ru: "Подбор ничего не нашёл" en: "The search found nothing" es: "La búsqueda no encontró nada" +analytics.ticks.stats_left_open: + ru: "вне выборки сделок, которые стратегия как есть не закрывает внутри ленты: %{n}" + en: "left out, deals the strategy as it stands does not close inside the tape: %{n}" + es: "fuera de la muestra, operaciones que la estrategia tal cual no cierra dentro de la cinta: %{n}" analytics.ticks.stats_line: ru: "перезапусков %{restarts} · лучший — №%{best} · %{passes} · разных итогов %{distinct} · без разрешённой точки %{refused} · оценено точек %{evals}" en: "restarts %{restarts} · best from #%{best} · %{passes} · distinct ends %{distinct} · with no allowed point %{refused} · points scored %{evals}" @@ -2077,9 +2097,13 @@ analytics.ticks.not_read: en: "not read" es: "no se lee" analytics.ticks.suggest_one_tip: - ru: "Подобрать только %{field}; остальные поля — как в В1" - en: "Search %{field} alone; the other fields as V1 has them" - es: "Buscar solo %{field}; los demás campos como en V1" + ru: "Подобрать только выделенное поле %{field}, галочка не учитывается; остальные поля держатся как в В1. Ответ пишется в ячейку %{field} столбца В1 — и в поля, которые включённый переключатель требует (UseTakeProfit → TakeProfit)" + en: "Search the selected field %{field} alone, ticked or not; the other fields stay as V1 has them. The answer goes into V1's %{field} cell — and into the fields a switch it turns on needs (UseTakeProfit → TakeProfit)" + es: "Buscar solo el campo seleccionado %{field}, marcado o no; los demás campos quedan como en V1. La respuesta va a la celda %{field} de V1 — y a los campos que requiere un interruptor que active (UseTakeProfit → TakeProfit)" +analytics.ticks.suggest_all_tip: + ru: "Подобрать все поля с галочкой в группах, прошедших порог воспроизводимости, — сейчас их %{n}. Найденная точка заменяет весь столбец В1; поле без галочки остаётся как есть, кроме значения, которое требует включённый подбором переключатель (UseTakeProfit → TakeProfit): оно одно на все стратегии" + en: "Search every ticked field of the groups past the reproduction gate — %{n} now. The point found replaces the whole V1 column; an unticked field stays as it is, but for a value a switch the search turns on needs (UseTakeProfit → TakeProfit): one value for every strategy" + es: "Buscar todos los campos marcados de los grupos que pasan el umbral de reproducción — ahora %{n}. El punto hallado reemplaza toda la columna V1; un campo sin marcar queda como está, salvo el valor que requiere un interruptor que la búsqueda active (UseTakeProfit → TakeProfit): un valor para todas las estrategias" analytics.ticks.suggest_one_none: ru: "Выберите поле: щёлкните по его имени в таблице" en: "Pick a field: click its name in the grid" From 2cfe077d7c42bd9e7ffaa0e484c1fcc09ea3bc08 Mon Sep 17 00:00:00 2001 From: guyverino Date: Thu, 24 Sep 2026 23:24:11 +0200 Subject: [PATCH 38/51] chore(tuner): drop the duplicates the rebase onto #717 left, keep the long-position boundary doc --- crates/moon-core/src/config/storage.rs | 6 +-- .../moon-core/src/db/tuner/strategy_read.rs | 52 ------------------- crates/moon-ui-gpui/src/settings/storage.rs | 39 -------------- 3 files changed, 3 insertions(+), 94 deletions(-) diff --git a/crates/moon-core/src/config/storage.rs b/crates/moon-core/src/config/storage.rs index f48826e8..f760223d 100644 --- a/crates/moon-core/src/config/storage.rs +++ b/crates/moon-core/src/config/storage.rs @@ -62,7 +62,7 @@ pub struct TradeReplayStoreCfg { /// longest ago go first. `0` keeps everything, with no age limit. pub max_mb: u32, /// Seconds of prints kept around a trade, per end: a short position gets this much before - /// its entry and after its exit; a long one ([`Self::long_position_min`] or longer) gets + /// its entry and after its exit; a long one (held past [`Self::long_position_min`]) gets /// this much on both sides of each end, with bars between. It sizes what a trade window /// fetches, what a close copies out of the core's ring, and what the file keeps. One of /// [`TRADE_MARGIN_STEPS_S`]: a hand-edited value is snapped to the nearest step on load. @@ -72,8 +72,8 @@ pub struct TradeReplayStoreCfg { /// missed because the terminal was not running. Off by default: it spends the venues' public /// request budget without being asked. A file written before the field reads as off. pub autoload_missing: bool, - /// Minutes a position must be held to count as LONG — walked as its two ends with bars - /// between, both by a trade window and by the tuner's fetch, whose clusters stay within it. + /// Minutes a position may be held and still count as SHORT; held past them it is LONG — + /// walked as its two ends with bars between, both by a trade window and by the tuner's fetch, whose clusters stay within it. /// Bounded to [`LONG_POSITION_MIN_RANGE`] on load; a file written before the field reads /// as the default, which is what the threshold was while it was a constant. pub long_position_min: u32, diff --git a/crates/moon-core/src/db/tuner/strategy_read.rs b/crates/moon-core/src/db/tuner/strategy_read.rs index d372e35f..388d60c6 100644 --- a/crates/moon-core/src/db/tuner/strategy_read.rs +++ b/crates/moon-core/src/db/tuner/strategy_read.rs @@ -313,58 +313,6 @@ fn flatten_values(raw: &str, keys: &[String]) -> Option std::collections::HashMap<(i64, u64), String> { - let mut out = std::collections::HashMap::new(); - if pairs.is_empty() { - return out; - } - let Some(conn) = open_strategies_ro() else { - log::warn!("[x] tuner: strategies.sqlite unavailable, strategy kinds unresolved"); - return out; - }; - let mut stmt = match conn.prepare( - "SELECT json_extract(v.raw_json, '$.SignalType') FROM strategy_versions v - WHERE v.strategy_id = ?1 AND v.core_uid = ?2 - ORDER BY v.valid_to IS NULL DESC, v.valid_from DESC LIMIT 1", - ) { - Ok(stmt) => stmt, - Err(error) => { - log::warn!("[x] tuner: strategy kinds query failed to prepare: {error}"); - return out; - } - }; - let mut failed = 0usize; - for &(strategy_id, core_uid) in pairs { - match stmt.query_row(rusqlite::params![strategy_id, core_uid as i64], |r| { - r.get::<_, Option>(0) - }) { - Ok(Some(kind)) => { - out.insert((strategy_id, core_uid), kind); - } - // No version at all, or a version without the field: genuinely unknown. - Ok(None) | Err(rusqlite::Error::QueryReturnedNoRows) => {} - Err(_) => failed += 1, - } - } - if failed > 0 { - log::warn!( - "[x] tuner: strategy kinds unresolved for {failed} of {} strategies (query errors)", - pairs.len() - ); - } - out -} - /// Threshold parameters of the SELECTED strategy for tuner fields. /// The source is the current strategies.sqlite version (raw_json normalized with schema /// defaults). `defaults` contains schema defaults (lowercase name -> number): a value EQUAL diff --git a/crates/moon-ui-gpui/src/settings/storage.rs b/crates/moon-ui-gpui/src/settings/storage.rs index 69e79a4a..c261907b 100644 --- a/crates/moon-ui-gpui/src/settings/storage.rs +++ b/crates/moon-ui-gpui/src/settings/storage.rs @@ -577,45 +577,6 @@ impl SettingsView { })), ) .child(self.trades_cleanup_controls(cx, p, busy)) - .child( - h_flex() - .flex_wrap() - .gap(design::ui_px(cx, 8.0)) - .items_center() - .child( - div() - .text_color(rgba_from(p.text, 1.0)) - .child(t!("storage.trades_long_position").to_string()), - ) - .child(self.stepper_controls( - cx, - "trades-long-position-min", - true, - t!("storage.trades_min", min = long_position_min).to_string(), - 1, - 5, - Self::adjust_long_position_min, - )), - ) - .child(hint(t!("storage.trades_long_position_hint").to_string())) - // The startup cleanup: read once per launch by the coordination tick, so the flip - // takes effect at the next launch — which is what "at startup" says. - .child( - moon_ui::MoonCheckbox::new("trades-cleanup-at-startup") - .checked(cleanup_at_startup) - .label(t!("storage.trades_cleanup_at_startup").to_string()) - .description(t!("storage.trades_cleanup_at_startup_hint").to_string()) - .on_change(cx.listener(|this, v: &bool, _, cx| { - let v = *v; - if this.storage.cfg.trade_replay.cleanup_at_startup != v { - this.storage.cfg.trade_replay.cleanup_at_startup = v; - moon_core::market::trade_replay::set_cleanup_at_startup(v); - storage_cfg::save(&this.storage.cfg); - cx.notify(); - } - })), - ) - .child(self.trades_cleanup_controls(cx, p, busy)) .child( h_flex().child( tool_btn("trades-compact", t!("storage.compact").to_string(), busy) From 1cecd146ce0ed19f90badd00aa693f46771f4fa6 Mon Sep 17 00:00:00 2001 From: guyverino Date: Fri, 25 Sep 2026 08:30:51 +0200 Subject: [PATCH 39/51] feat(tuner): search the Delta Modifiers section 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= to run the section's search per strategy on a replica. --- crates/moon-core/src/db/tuner/ticks/params.rs | 121 ++++++-- .../src/db/tuner/ticks/params/tests.rs | 26 ++ crates/moon-core/src/db/tuner/ticks/search.rs | 40 ++- .../src/db/tuner/ticks/search/coupled.rs | 276 ++++++++++++++++++ .../db/tuner/ticks/search/coupled/tests.rs | 237 +++++++++++++++ .../src/db/tuner/ticks/search/tests.rs | 2 + .../src/db/tuner/ticks/tests/real_data.rs | 21 +- .../db/tuner/ticks/tests/real_data/search.rs | 128 ++++++++ .../src/analytics/tuner/ticks/variants.rs | 13 +- 9 files changed, 824 insertions(+), 40 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/search/coupled.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/coupled/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index b5882ece..e17d4810 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -198,6 +198,29 @@ const GRID_TRAILING: &[f64] = &[ const GRID_TRAILING_EMA: &[f64] = &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; /// `TakeProfit` of the trailing, per cent off the buy: live 1, 2, 2.5 and 5. const GRID_TAKE_PROFIT: &[f64] = &[0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 5.0, 10.0]; +/// `SellModifier`, per cent of the sell's price per one per cent of the summed deltas: live 0.03 +/// to 1.5 among the 201 strategies of 1 423 that set it (2026-09-25; 0.5 at 114 of them). Below +/// zero a volatile coin's sell comes nearer the entry, which the core allows. +const GRID_SELL_MODIFIER: &[f64] = &[ + -0.5, -0.3, -0.2, -0.1, -0.05, 0.0, 0.03, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.75, 1.0, + 1.5, +]; +/// `StopLossModifier`: live 0.2 at 139 of 150 strategies, −0.3 and −0.1 at the rest. +const GRID_STOP_MODIFIER: &[f64] = &[ + -0.5, -0.3, -0.2, -0.1, -0.05, 0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 1.0, +]; +/// `MaxModifier`, per cent of summed deltas; 0 caps nothing. Live 10 to 1 000 (30 at 47 of 127); +/// the small steps are what lets a cap bite on a quiet coin. +const GRID_MAX_MODIFIER: &[f64] = &[ + 0.0, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 100.0, 130.0, 200.0, 1000.0, +]; +/// The `Add*` terms of the Delta Modifiers tab, per one per cent of their delta: live from 0.002 +/// (`Add3hDelta`) to 3 (`Add1minDelta`), none below zero (2026-09-25). The sum is taken as a +/// magnitude, so a single term's sign moves nothing. +const GRID_DELTA_ADD: &[f64] = &[ + 0.0, 0.001, 0.002, 0.005, 0.01, 0.02, 0.03, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0, + 1.5, 2.0, 3.0, +]; /// Every parameter of the axis, grid order: the Entry group first, then Exit. pub const TICK_PARAMS: &[TickParam] = &[ @@ -452,8 +475,55 @@ pub const TICK_PARAMS: &[TickParam] = &[ exit_num("TrailingEMA", ParamSection::Stops, GRID_TRAILING_EMA), exit_bool("UseTakeProfit", ParamSection::Stops), exit_num("TakeProfit", ParamSection::Stops, GRID_TAKE_PROFIT), + // The Delta Modifiers section: one capped sum of the trade's deltas, spent on the sell and on + // the stop (`exit::delta_mods`). It is a product, and the search walks it as one + // (`search::coupled`). `BuyModifier` and `DetectModifier` move the entry and the detect, + // which the model takes from the fact for every kind but MoonShot, whose core ignores them. + exit_num( + "SellModifier", + ParamSection::DeltaModifiers, + GRID_SELL_MODIFIER, + ), + exit_num( + "StopLossModifier", + ParamSection::DeltaModifiers, + GRID_STOP_MODIFIER, + ), + TickParam { + key: "MaxModifier", + group: ParamGroup::Exit, + section: ParamSection::DeltaModifiers, + kind: ParamKind::Num { + grid: GRID_MAX_MODIFIER, + }, + kinds: ANY, + // One field, two families: a MoonShot's cap also bounds its `MShotAdd*` corridor + // (`mshot_params`), so turning it in an Exit search would move the entry the search + // leaves alone, past every corridor guard. There it stays at the strategy's value. + not_kinds: MSHOT, + }, + delta_add("Add1minDelta"), + delta_add("Add5minDelta"), + delta_add("Add15minDelta"), + delta_add("AddHourlyDelta"), + delta_add("Add3hDelta"), + delta_add("Add24hDelta"), + delta_add("AddMarketDelta"), + delta_add("AddMarket24Delta"), + delta_add("AddBTCDelta"), + delta_add("AddBTC5mDelta"), + delta_add("AddBTC1mDelta"), + delta_add("AddMarkDelta"), + delta_add("AddPump1h"), + delta_add("AddDump1h"), + delta_add("AddPriceBug"), ]; +/// An `Add*` term of the Delta Modifiers section. +const fn delta_add(key: &'static str) -> TickParam { + exit_num(key, ParamSection::DeltaModifiers, GRID_DELTA_ADD) +} + /// A numeric field of the Exit group every kind understands. const fn exit_num(key: &'static str, section: ParamSection, grid: &'static [f64]) -> TickParam { TickParam { @@ -498,14 +568,12 @@ pub fn params_for<'k>( /// while every test passed. /// /// `HookSellFixed` is here because it is not a knob (the branch it selects is not modelled) but -/// its value decides whether the take is known at all; the `Add*` family and its two -/// coefficients are here because they move the level of every kind, and none of them is -/// something the search should turn. +/// its value decides whether the take is known at all. The Delta Modifiers section is a knob +/// since 2026-09-25, all but `MaxModifier` on a MoonShot, which the grid draws fixed there +/// (see its [`TICK_PARAMS`] entry) — so it is listed here as well. const MODEL_ONLY_KEYS: &[&str] = &[ "HookSellFixed", - "SellModifier", "MaxModifier", - "StopLossModifier", // The stop's trigger (see `ExitParams::fast_stop_loss`): read with the strategy's value, no // knob. "FastStopLoss", @@ -513,21 +581,6 @@ const MODEL_ONLY_KEYS: &[&str] = &[ // core's own spelling of the field. "PumpMoveTimer", "PumpMovePersent", - "Add1minDelta", - "Add5minDelta", - "Add15minDelta", - "AddHourlyDelta", - "Add3hDelta", - "Add24hDelta", - "AddMarkDelta", - "AddPriceBug", - "AddBTCDelta", - "AddBTC1mDelta", - "AddBTC5mDelta", - "AddMarketDelta", - "AddMarket24Delta", - "AddPump1h", - "AddDump1h", // The corridor family's one modifier the grid does not offer (no live strategy sets it). "MShotAdd5sDelta", ]; @@ -544,6 +597,20 @@ const RULE_SWITCH_KEYS: &[&str] = &[ "IgnoreSellSpread", ]; +/// Exit fields the entry model reads as well: a MoonShot's `MaxModifier` caps its `MShotAdd*` +/// corridor too ([`mshot_params`]). +const ENTRY_SHARED_KEYS: &[&str] = &["MaxModifier"]; + +/// Whether writing `key` can move a MoonShot's entry corridor — an Entry field, or an Exit field +/// the entry model reads too. What a write's corridor warning keys on: the group alone misses +/// `MaxModifier`, which a mixed-kind scope offers as a knob and Save writes to every strategy. +pub fn moves_entry(key: &str) -> bool { + ENTRY_SHARED_KEYS.contains(&key) + || TICK_PARAMS + .iter() + .any(|f| f.key == key && f.group == ParamGroup::Entry) +} + /// Whether the models read `key` from the strategy without the grid offering it as a knob — /// the grid draws such a field as fixed rather than as outside the model. pub fn is_model_only(key: &str) -> bool { @@ -551,15 +618,21 @@ pub fn is_model_only(key: &str) -> bool { } /// Every field name the models read — [`TICK_PARAMS`], [`MODEL_ONLY_KEYS`] and the switches of -/// the rules they do not have ([`RULE_SWITCH_KEYS`]) — for a `strategy_current_values` read. +/// the rules they do not have ([`RULE_SWITCH_KEYS`]) — for a `strategy_current_values` read, +/// each once: a knob for some kinds is model-only for others (`MaxModifier`). pub fn param_keys() -> Vec { - TICK_PARAMS + let mut keys: Vec = Vec::new(); + for key in TICK_PARAMS .iter() .map(|p| p.key) .chain(MODEL_ONLY_KEYS.iter().copied()) .chain(RULE_SWITCH_KEYS.iter().copied()) - .map(str::to_string) - .collect() + { + if !keys.iter().any(|k| k == key) { + keys.push(key.to_string()); + } + } + keys } /// Strategy values as `strategy_current_values` hands them (strings, `YES`/`NO` booleans) plus diff --git a/crates/moon-core/src/db/tuner/ticks/params/tests.rs b/crates/moon-core/src/db/tuner/ticks/params/tests.rs index 304b1ed4..b6cd1bf0 100644 --- a/crates/moon-core/src/db/tuner/ticks/params/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/params/tests.rs @@ -18,3 +18,29 @@ fn the_corridor_grids_step_by_a_twentieth_to_eight() { assert_eq!(format!("{}", grid[33]), "1.7"); } } + +/// A write's corridor warning must key on every field that moves a MoonShot's corridor: the +/// Entry group, and `MaxModifier` of the Exit group, which caps the `MShotAdd*` sum too — and on +/// no field the corridor does not read. +#[test] +fn max_modifier_moves_the_entry_like_the_entry_fields() { + assert!(moves_entry("MaxModifier")); + assert!(moves_entry("MShotPrice") && moves_entry("MShotAddHourlyDelta")); + assert!(!moves_entry("SellModifier") && !moves_entry("Add1minDelta")); + assert!(!moves_entry("SellPrice")); + // The shared field is read by the entry builder: were it not, the warning would be noise. + let values: HashMap = [ + ("MaxModifier".to_string(), "2".to_string()), + ("MShotAddHourlyDelta".to_string(), "1".to_string()), + ] + .into(); + let defaults = HashMap::new(); + let sv = StrategyValues { + values: &values, + defaults: &defaults, + }; + assert_eq!( + mshot_params(&sv, ModelSettings::default()).max_modifier, + 2.0 + ); +} diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index 4febc129..73dcbdff 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -215,8 +215,10 @@ struct Walked { /// grid, keeping any value that beats the score; a pass that moves no single field then tries /// the pairs — one field of `pairs` a step down, another a step up — so a distance shared /// between two fields can move from one to the other, which no single move reaches when each -/// alone makes the score worse or breaks a corridor rule. The walk ends when a pass moves -/// nothing, or at `max_passes`. +/// alone makes the score worse or breaks a corridor rule. The Delta Modifiers section is a +/// product ([`coupled`]): a field of it that moves nothing at the point is not scanned, and the +/// same stalled pass walks each (coefficient, term) pair stuck at zero along a diagonal of their +/// grids. The walk ends when a pass moves nothing, or at `max_passes`. /// /// Returns: /// Where it stopped, or `None` when the run was stopped. @@ -225,6 +227,7 @@ fn descend( mut point: Point, order: &[&'static TickParam], pairs: &[&'static TickParam], + coupling: &coupled::Coupling<'_>, start: &HashMap<&'static str, usize>, evaluate: &(dyn Fn(&Point) -> Option + Sync), min_n: i64, @@ -241,6 +244,9 @@ fn descend( handle.note_abandoned(); return None; } + if coupling.inert(field, &point) { + continue; + } let mut current = point.get(field.key).cloned(); for index in 0..arity(&field.kind) { let candidate = spell(&field.kind, index); @@ -296,6 +302,23 @@ fn descend( } } } + for (coefficient, term) in coupling.stuck(&point) { + // A path walked earlier in this pass may have freed the pair. + if !coupling.is_stuck(coefficient, term, &point) { + continue; + } + for path in coupled::Coupling::diagonals(coefficient, term) { + improved |= coupled::walk_path( + &mut point, + (coefficient, term), + &path, + evaluate, + &mut score, + min_n, + handle, + )?; + } + } } if !improved { converged = true; @@ -783,10 +806,14 @@ pub fn suggest( std::sync::atomic::AtomicUsize::new(0), std::sync::atomic::AtomicUsize::new(0), ); - let evaluate = |point: &Point| -> Option { - // Every number field the point switches on stands at a value (`deps`). + // Every number field the point switches on stands at a value (`deps`). + let per_base_at = |point: &Point| { let full = deps.complete(point, &bases.owns, params.held, params.defaults); - let per_base = bases.params(params.held, params.defaults, &full, params.kind, model); + bases.params(params.held, params.defaults, &full, params.kind, model) + }; + let coupling = coupled::Coupling::of(&fields, &per_base_at); + let evaluate = |point: &Point| -> Option { + let per_base = per_base_at(point); // A point that inverts the corridor's two fields is never proposed, whatever the // switch: the searched fields are gridded one by one, and nothing else ties them. Only // a search of the Entry group can produce one — an Exit search leaves the strategy's @@ -843,7 +870,7 @@ pub fn suggest( perturb(&mut point, &order, &start, &mut state); } let walked = descend( - point, &order, &pairs, &start, &evaluate, min_n, max_passes, handle, + point, &order, &pairs, &coupling, &start, &evaluate, min_n, max_passes, handle, )?; handle.record_restart(); Some(Run { @@ -1138,6 +1165,7 @@ fn point_of(values: &[(String, String)]) -> Point { mod closing; pub use self::closing::unguarded_strategies; +mod coupled; mod deps; #[cfg(test)] diff --git a/crates/moon-core/src/db/tuner/ticks/search/coupled.rs b/crates/moon-core/src/db/tuner/ticks/search/coupled.rs new file mode 100644 index 00000000..dcfacb0a --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/coupled.rs @@ -0,0 +1,276 @@ +//! The Delta Modifiers section inside the search: a product, not a set of independent fields. +//! +//! The section moves a level by `coefficient · Min(MaxModifier, |Σ Pn·Dn|)` — the coefficient is +//! `SellModifier` on the sell and `StopLossModifier` on the stop, the `Pn` are the `Add*` terms +//! (`exit::delta_mods`). Coordinate descent turns one field at a time, and on a product that has +//! two consequences: +//! +//! - a field whose partner is zero moves nothing — every `Add*` while both coefficients are zero, +//! a coefficient while every term is zero, the cap while either is — and scanning its grid +//! is a replay of the sample per step that cannot change the score. Such a field is skipped +//! ([`Coupling::inert`]); +//! - from the corner where the coefficient and the terms are all zero — the state of most +//! strategies — no single move leaves it: each field is inert while the other is zero. The +//! pass that moves no single field then walks each (coefficient, term) pair from that corner +//! together, along a diagonal of their grids ([`Coupling::diagonals`]), from the smallest +//! product to the largest, so the section can be switched on at all. +//! +//! A field is inert only when it is inert on EVERY strategy of the sample: a variant's value is +//! one value for all of them, and one strategy that applies the sum is enough for the field to +//! move a column. + +use super::{Point, better_score, restore, spell}; +use crate::db::metrics::Tally; +use crate::db::tuner::threshold_search::SearchHandle; +use crate::db::tuner::ticks::params::{ParamKind, ParamSection, TickParam}; +use crate::db::tuner::ticks::{EntryParams, ExitParams}; + +/// The model parameters of every strategy of the sample at a point, completed as a scored +/// point is. +pub(super) type PerBase<'a> = dyn Fn(&Point) -> Vec<(EntryParams, ExitParams)> + Sync + 'a; + +/// What a field of the Delta Modifiers section is to the product. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Role { + /// `SellModifier`: spends the sum on the sell. + SellCoefficient, + /// `StopLossModifier`: spends the sum on the stop. + StopCoefficient, + /// `MaxModifier`: caps the sum. + Cap, + /// One of the `Add*` terms of the sum. + Term, +} + +/// The role of `field`, or `None` for a field outside the section. +fn role(field: &TickParam) -> Option { + if field.section != ParamSection::DeltaModifiers { + return None; + } + Some(match field.key { + "SellModifier" => Role::SellCoefficient, + "StopLossModifier" => Role::StopCoefficient, + "MaxModifier" => Role::Cap, + _ => Role::Term, + }) +} + +/// The Delta Modifiers fields of one search and how to read their partners at a point. +pub(super) struct Coupling<'a> { + /// Every (coefficient, term) pair among the varied fields. + pairs: Vec<(&'static TickParam, &'static TickParam)>, + /// The strategies' parameters at a point; `None` couples nothing. + per_base: Option<&'a PerBase<'a>>, +} + +impl<'a> Coupling<'a> { + /// A search that couples nothing: no field is inert, no diagonal is walked — what the tests + /// of the plain descent run under. + #[cfg(test)] + pub(super) fn none() -> Self { + Self { + pairs: Vec::new(), + per_base: None, + } + } + + /// Args: + /// fields: The fields the search varies. + /// per_base: The strategies' parameters at a point. + pub(super) fn of(fields: &[&'static TickParam], per_base: &'a PerBase<'a>) -> Self { + let coefficients = fields + .iter() + .filter(|f| matches!(role(f), Some(Role::SellCoefficient | Role::StopCoefficient))); + let pairs = coefficients + .flat_map(|&c| { + fields + .iter() + .filter(|t| role(t) == Some(Role::Term)) + .map(move |&t| (c, t)) + }) + .collect(); + Self { + pairs, + per_base: Some(per_base), + } + } + + /// Whether turning `field` at `point` can move nothing, on every strategy of the sample. + pub(super) fn inert(&self, field: &TickParam, point: &Point) -> bool { + let (Some(role), Some(per_base)) = (role(field), self.per_base) else { + return false; + }; + per_base(point).iter().all(|(_, exit)| inert_on(role, exit)) + } + + /// The (coefficient, term) pairs stuck at `point` ([`Self::is_stuck`]). + pub(super) fn stuck(&self, point: &Point) -> Vec<(&'static TickParam, &'static TickParam)> { + self.pairs + .iter() + .filter(|(c, t)| self.is_stuck(c, t, point)) + .copied() + .collect() + } + + /// Whether a (coefficient, term) pair is stuck at `point`: the coefficient is off (zero on + /// every strategy) and the sum has no term on any, so the coefficient's side cannot be + /// switched on by one field — its own scan moves nothing without a term, and a term's scan + /// is judged through the other coefficient only, or through nothing — while the coefficient + /// has a level to spend the sum on: a stop on some strategy for `StopLossModifier`. + /// + /// A coefficient already set is not stuck: a term's own scan spends through it. One with no + /// level anywhere is never walked: its value would move nothing, and a step that pays + /// through the other coefficient would keep that value in the answer Save writes. + pub(super) fn is_stuck( + &self, + coefficient: &TickParam, + term: &TickParam, + point: &Point, + ) -> bool { + let (Some(c), Some(Role::Term), Some(per_base)) = + (role(coefficient), role(term), self.per_base) + else { + return false; + }; + let bases = per_base(point); + bases + .iter() + .all(|(_, exit)| silent(exit) && coefficient_of(c, exit) == 0.0) + && bases.iter().any(|(_, exit)| can_spend(c, exit)) + } + + /// The paths a stuck pair is walked along, each a list of (coefficient, term) values in + /// strategy spelling: the coefficient's grid off zero on each side — up, and down where the + /// grid goes below zero — against the term's grid above zero, both spanned end to end over + /// the longer of the two, so a path runs from the smallest product to the largest. + pub(super) fn diagonals( + coefficient: &TickParam, + term: &TickParam, + ) -> Vec> { + let (Some(up), Some(down), Some(terms)) = ( + steps(coefficient, |v| v > 0.0), + steps(coefficient, |v| v < 0.0), + steps(term, |v| v > 0.0), + ) else { + return Vec::new(); + }; + [up, down] + .into_iter() + .filter(|side| !side.is_empty() && !terms.is_empty()) + .map(|side| { + let n = side.len().max(terms.len()); + (0..n) + .map(|k| { + let at = |len: usize| { + if n == 1 { + 0 + } else { + (k * (len - 1) + (n - 1) / 2) / (n - 1) + } + }; + ( + spell(&coefficient.kind, side[at(side.len())]), + spell(&term.kind, terms[at(terms.len())]), + ) + }) + .collect::>() + }) + .collect() + } +} + +/// Whether a field of `role` moves nothing on one strategy's sell parameters. +fn inert_on(role: Role, exit: &ExitParams) -> bool { + let sell_spends = exit.sell_modifier != 0.0; + // `exit::stops::stop_pct`: no stop, or no coefficient on it, spends nothing. + let stop_spends = exit.stop_loss_modifier != 0.0 && exit.stop_loss_pct != 0.0; + let no_terms = silent(exit); + match role { + Role::SellCoefficient => no_terms, + Role::StopCoefficient => no_terms || exit.stop_loss_pct == 0.0, + Role::Term => !sell_spends && !stop_spends, + Role::Cap => no_terms || (!sell_spends && !stop_spends), + } +} + +/// The value of a coefficient of `role` on one strategy; 0 for a role that is no coefficient. +fn coefficient_of(role: Role, exit: &ExitParams) -> f64 { + match role { + Role::SellCoefficient => exit.sell_modifier, + Role::StopCoefficient => exit.stop_loss_modifier, + Role::Cap | Role::Term => 0.0, + } +} + +/// Whether a coefficient of `role` has a level to spend the sum on, on one strategy: the sell +/// always, the stop only where there is one (`exit::stops::stop_pct`). +fn can_spend(role: Role, exit: &ExitParams) -> bool { + match role { + Role::StopCoefficient => exit.stop_loss_pct != 0.0, + _ => true, + } +} + +/// Whether the sum has no term: every `Add*` coefficient at zero. Compared against the family +/// with its terms zeroed rather than field by field, so a term added to `Modifiers` later is +/// not left out of the question. +fn silent(exit: &ExitParams) -> bool { + let m = &exit.sell_mods; + *m == crate::db::tuner::ticks::mshot::Modifiers { + market_sign: m.market_sign, + distance_pct: m.distance_pct, + pricebug_cap: m.pricebug_cap, + ..Default::default() + } +} + +/// The grid indices of a number field whose value passes `keep`, nearest zero first; `None` +/// for a field that is not a number. +fn steps(field: &TickParam, keep: impl Fn(f64) -> bool) -> Option> { + let ParamKind::Num { grid } = &field.kind else { + return None; + }; + let mut out: Vec = (0..grid.len()).filter(|&i| keep(grid[i])).collect(); + out.sort_by(|&a, &b| grid[a].abs().total_cmp(&grid[b].abs())); + Some(out) +} + +/// Walk one path of two fields as one move: each step sets both, and a step that beats the +/// score is kept — the walk goes on from it, as a single field's scan does. +/// +/// Returns: +/// Whether a step was kept, or `None` when the run was stopped — checked before every +/// step, each a replay of the sample. +pub(super) fn walk_path( + point: &mut Point, + (a, b): (&'static TickParam, &'static TickParam), + path: &[(String, String)], + evaluate: &(dyn Fn(&Point) -> Option + Sync), + score: &mut Option, + min_n: i64, + handle: &SearchHandle, +) -> Option { + let (mut kept_a, mut kept_b) = (point.get(a.key).cloned(), point.get(b.key).cloned()); + let mut improved = false; + for (va, vb) in path { + if handle.is_cancelled() { + handle.note_abandoned(); + return None; + } + point.insert(a.key, va.clone()); + point.insert(b.key, vb.clone()); + let trial = evaluate(point); + if better_score(&trial, score, min_n) { + *score = trial; + improved = true; + (kept_a, kept_b) = (Some(va.clone()), Some(vb.clone())); + } else { + restore(point, a.key, kept_a.clone()); + restore(point, b.key, kept_b.clone()); + } + } + Some(improved) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/search/coupled/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/coupled/tests.rs new file mode 100644 index 00000000..3ac4a2b3 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/coupled/tests.rs @@ -0,0 +1,237 @@ +//! The Delta Modifiers section as the search walks it. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::*; +use crate::db::tuner::ticks::TICK_PARAMS; +use crate::db::tuner::ticks::params::{StrategyValues, exit_params}; +use crate::db::tuner::ticks::search::{DEFAULT_MAX_PASSES, descend}; +use crate::db::tuner::ticks::settings::ModelSettings; + +fn field(key: &str) -> &'static TickParam { + TICK_PARAMS + .iter() + .find(|f| f.key == key) + .expect("a grid field") +} + +/// One strategy per base, its values the point laid over `own`. +fn bases(owns: Vec>) -> impl Fn(&Point) -> Vec<(EntryParams, ExitParams)> { + move |point: &Point| { + owns.iter() + .map(|own| { + let mut values = own.clone(); + for (k, v) in point { + values.insert((*k).to_string(), v.clone()); + } + let defaults = HashMap::new(); + let sv = StrategyValues { + values: &values, + defaults: &defaults, + }; + ( + EntryParams::Fact, + exit_params(&sv, ModelSettings::default()), + ) + }) + .collect() + } +} + +fn own(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +/// A term moves nothing while no strategy spends the sum, and something as soon as one does — +/// on the sell, or on a stop that exists; a coefficient moves nothing without a term, the stop's +/// also without a stop. +#[test] +fn a_field_is_inert_only_while_its_partner_is_zero_on_every_strategy() { + let add = field("Add1minDelta"); + let sell = field("SellModifier"); + let stop = field("StopLossModifier"); + let cap = field("MaxModifier"); + let fields = [sell, stop, cap, add]; + + let zero = bases(vec![own(&[("StopLoss", "-2")]), own(&[("StopLoss", "-2")])]); + let coupling = Coupling::of(&fields, &zero); + let at = Point::new(); + assert!(coupling.inert(add, &at)); + assert!(coupling.inert(sell, &at)); + assert!(coupling.inert(cap, &at)); + + // One of two strategies spends the sum on its sell: the term moves that one. + let one = bases(vec![ + own(&[("SellModifier", "0.5")]), + own(&[("StopLoss", "-2")]), + ]); + let coupling = Coupling::of(&fields, &one); + assert!(!coupling.inert(add, &at)); + // …but the coefficient still has no term to spend. + assert!(coupling.inert(sell, &at)); + + // A stop coefficient spends the sum only on a stop that exists. + let no_stop = bases(vec![own(&[ + ("StopLossModifier", "0.2"), + ("UseStopLoss", "NO"), + ("Add1minDelta", "1"), + ])]); + let coupling = Coupling::of(&fields, &no_stop); + assert!(coupling.inert(add, &at)); + assert!(coupling.inert(stop, &at)); + assert!(!coupling.inert(sell, &at), "a term is there to spend"); + + // A field outside the section is never inert. + assert!(!coupling.inert(field("SellPrice"), &at)); +} + +/// A diagonal spans both grids end to end, off zero, from the smallest product to the largest: +/// the coefficient's grid up and down, the term's up. +#[test] +fn a_diagonal_runs_both_grids_from_the_smallest_step_to_the_largest() { + let paths = Coupling::diagonals(field("SellModifier"), field("Add1minDelta")); + assert_eq!(paths.len(), 2, "up and down"); + let pair = |p: &(String, String)| (p.0.clone(), p.1.clone()); + let s = |a: &str, b: &str| (a.to_string(), b.to_string()); + let up = &paths[0]; + assert_eq!(pair(&up[0]), s("0.03", "0.001")); + assert_eq!(pair(up.last().expect("a step")), s("1.5", "3")); + let down = &paths[1]; + assert_eq!(pair(&down[0]), s("-0.05", "0.001")); + assert_eq!(pair(down.last().expect("a step")), s("-0.5", "3")); + for path in &paths { + // As long as the longer grid, so each of its steps is visited once. + assert_eq!(path.len(), 18); + assert!(path.iter().all(|(c, t)| c != "0" && t != "0")); + } +} + +/// From the corner where everything is zero, no single move helps and the descent walks the +/// pair together; without the coupling it stays where it began. +#[test] +fn the_descent_leaves_the_zero_corner_along_the_diagonal() { + let sell = field("SellModifier"); + let add = field("Add1minDelta"); + let per_base = bases(vec![own(&[])]); + // The best sell is lifted by 0.2 to 1 per cent per one per cent of the 1-minute delta. + let evaluate = |point: &Point| -> Option { + let (_, exit) = &per_base(point)[0]; + let lift = exit.sell_modifier * exit.sell_mods.add_1m; + let mut tally = Tally::default(); + tally.push(if (0.2..=1.0).contains(&lift) { + 10.0 + } else { + 1.0 + }); + Some(tally) + }; + let order = [sell, add]; + let start: HashMap<&'static str, usize> = HashMap::new(); + let coupling = Coupling::of(&order, &per_base); + let walked = descend( + Point::new(), + &order, + &[], + &coupling, + &start, + &evaluate, + 1, + DEFAULT_MAX_PASSES, + &SearchHandle::new(), + ) + .expect("not stopped"); + let (_, exit) = &per_base(&walked.point)[0]; + let lift = exit.sell_modifier * exit.sell_mods.add_1m; + assert!((0.2..=1.0).contains(&lift), "{:?}", walked.point); + assert!((walked.score.expect("scored").profit - 10.0).abs() < 1e-9); + + let alone = descend( + Point::new(), + &order, + &[], + &Coupling::none(), + &start, + &evaluate, + 1, + DEFAULT_MAX_PASSES, + &SearchHandle::new(), + ) + .expect("not stopped"); + assert!(alone.point.is_empty(), "{:?}", alone.point); +} + +/// A term no strategy spends is not scanned at all: its grid would be a replay per step that +/// cannot move the score. +#[test] +fn an_inert_term_costs_no_replay() { + let add = field("Add1minDelta"); + let per_base = bases(vec![own(&[])]); + let replays = AtomicUsize::new(0); + let evaluate = |_: &Point| -> Option { + replays.fetch_add(1, Ordering::Relaxed); + let mut tally = Tally::default(); + tally.push(1.0); + Some(tally) + }; + let order = [add]; + // No coefficient is searched, so no diagonal either: only the scan could replay. + let coupling = Coupling::of(&order, &per_base); + descend( + Point::new(), + &order, + &[], + &coupling, + &HashMap::new(), + &evaluate, + 1, + DEFAULT_MAX_PASSES, + &SearchHandle::new(), + ) + .expect("not stopped"); + assert_eq!( + replays.load(Ordering::Relaxed), + 1, + "only the start is scored" + ); +} + +/// A pair is stuck only while its coefficient is off everywhere, the sum has no term, and the +/// coefficient has a level to spend on: a coefficient already set spends through a term's own +/// scan, and a stop coefficient with no stop anywhere is never walked — its value would move +/// nothing and still be written. A stop coefficient that is set does not free the sell's pair. +#[test] +fn a_pair_is_stuck_only_while_its_coefficient_is_off_and_can_spend() { + let sell = field("SellModifier"); + let stop = field("StopLossModifier"); + let add = field("Add1minDelta"); + let fields = [sell, stop, add]; + let at = Point::new(); + + // Everything at zero with a stop: both coefficients can spend once a term is there. + let corner = bases(vec![own(&[("StopLoss", "-2")])]); + let coupling = Coupling::of(&fields, &corner); + assert_eq!(coupling.stuck(&at), vec![(sell, add), (stop, add)]); + + // No stop anywhere: the stop coefficient is not walked. + let no_stop = bases(vec![own(&[("UseStopLoss", "NO")])]); + let coupling = Coupling::of(&fields, &no_stop); + assert_eq!(coupling.stuck(&at), vec![(sell, add)]); + + // The stop spends the sum and no term is set: a term's scan pays through the stop only, so + // the sell's pair is still walked — the stop's is not. + let stop_set = bases(vec![own(&[ + ("StopLossModifier", "0.2"), + ("StopLoss", "-2"), + ])]); + let coupling = Coupling::of(&fields, &stop_set); + assert_eq!(coupling.stuck(&at), vec![(sell, add)]); + + // A term is set: no coefficient is stuck, each moves on its own. + let term_set = bases(vec![own(&[("Add1minDelta", "1"), ("StopLoss", "-2")])]); + let coupling = Coupling::of(&fields, &term_set); + assert!(coupling.stuck(&at).is_empty()); +} diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index c707cf3f..df29b924 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -644,6 +644,7 @@ fn a_pair_move_reaches_what_no_single_move_does() { Point::new(), &order, &order, + &coupled::Coupling::none(), &start, &evaluate, 1, @@ -665,6 +666,7 @@ fn a_pair_move_reaches_what_no_single_move_does() { Point::new(), &order, &[], + &coupled::Coupling::none(), &start, &evaluate, 1, diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 4f566f58..70732705 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -13,8 +13,9 @@ //! `MOON_TICKS_LATENCY_BASE_MS` with that plus each core's archived round trip, the entry's only //! unless `MOON_TICKS_LATENCY_EXIT` is set; `MOON_TICKS_PATH_DEBUG` prints each order's modelled //! and archived path; `MOON_TICKS_VARIANT="Key=value,…"` replays each deal under those values laid -//! over its own and prints the line it walked); the application never moves its data root on a -//! variable. +//! over its own and prints the line it walked; `MOON_TICKS_SEARCH=` runs the search over +//! that kind's fit deals, the Delta Modifiers section alone, and prints what it found); the +//! application never moves its data root on a variable. use std::collections::HashMap; use std::path::PathBuf; @@ -37,6 +38,8 @@ use crate::market::kline_cache::KlineCache; use crate::market::trade_replay::{Coverage, TickQuery, long_position_ms, query_held}; use crate::symbol::{coin_match_key, coin_of_market}; +mod search; + /// One trade's d1m and d5m errors at the report's stamp (`StampCheck::error`). type ShortErrors = (Option, Option); @@ -636,6 +639,8 @@ fn real_data_reproduction() { let partners = partners_of(&read.deals, &venue_of_core, &keys, &defaults); let mut partner_tally = PartnerTally::default(); let (mut own_sum, mut fact_sum) = (0.0f64, 0.0f64); + let search_kind = std::env::var("MOON_TICKS_SEARCH").ok(); + let mut searched: Vec = Vec::new(); for mut deal in read.deals { *kinds_seen.entry(deal.kind.clone()).or_default() += 1; // Every kind the axis takes, as the table does: a kind without an entry model replays @@ -1088,6 +1093,15 @@ fn real_data_reproduction() { own.exit.map(|e| e.t_ms - deal.close_ms), round3(fact), ); + // `MOON_TICKS_SEARCH=`: the fit deals of that kind go to a search after the loop. + if fit && search_kind.as_deref() == Some(deal.kind.as_str()) { + searched.push(search::prepared( + &deal, + &ticks, + entry_line.as_deref(), + &values, + )); + } if fit { fit_n += 1; if let (Some(own), Some(fact)) = (own.profit_pct, fact) { @@ -1140,6 +1154,9 @@ fn real_data_reproduction() { exit_hits += usize::from(ok); } } + if let Some(kind) = &search_kind { + search::run(searched, kind, &defaults); + } eprintln!("kinds: {kinds_seen:?}"); eprintln!( "with tape: {with_tape} · entry ✓ {entry_hits}/{entry_n} · exit ✓ {exit_hits}/{exit_n} · \ diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs new file mode 100644 index 00000000..86671758 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs @@ -0,0 +1,128 @@ +//! `MOON_TICKS_SEARCH=` of the real-data bench: the axis' search over the fit deals of each +//! of the kind's five strategies with the most of them — one strategy at a time, as the axis +//! searches a selection — the Delta Modifiers section alone — every other field locked at each strategy's own +//! value — so what the section can add over the strategies as they stand is read off a real +//! replica, with no window. `MOON_TICKS_SEARCH_RESTARTS` sets the restarts (10 by default). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Instant; + +use crate::db::tuner::threshold_search::SearchHandle; +use crate::db::tuner::ticks::params::ParamSection; +pub(super) use crate::db::tuner::ticks::search::PreparedDeal; +use crate::db::tuner::ticks::search::{ + DEFAULT_MAX_PASSES, SearchParams, clip_to_horizon, common_horizon_ms, suggest, variant_tally, +}; +use crate::db::tuner::ticks::{Deal, ModelSettings, TICK_PARAMS}; +use crate::feed::types::Tick; + +/// One fit deal as the search takes it: its tape, its entry line, and the values its strategy +/// held at the buy as the base — the app lays the strategy's CURRENT values instead. +pub(super) fn prepared( + deal: &Deal, + ticks: &[Tick], + entry_line: Option<&[(i64, f64)]>, + values: &HashMap, +) -> PreparedDeal { + let last_ms = ticks.last().map_or(deal.close_ms, |t| t.time_ms as i64); + PreparedDeal { + deal: deal.clone(), + ticks: Arc::from(ticks), + entry_line: entry_line.map(Arc::from), + trail_ms: (last_ms - deal.close_ms).max(0), + own: Arc::new(values.clone()), + } +} + +/// Run the search on each of the five strategies with the most deals. +pub(super) fn run(deals: Vec, kind: &str, defaults: &HashMap) { + let mut by_strategy: HashMap<(i64, u64), Vec> = HashMap::new(); + for deal in deals { + by_strategy + .entry((deal.deal.strategy_id, deal.deal.core_uid)) + .or_default() + .push(deal); + } + let mut groups: Vec> = by_strategy.into_values().collect(); + groups.sort_by_key(|g| std::cmp::Reverse(g.len())); + for group in groups.into_iter().take(5) { + run_one(group, kind, defaults); + } +} + +/// Run the search and print what it found, how long it took and how it went. +fn run_one(mut deals: Vec, kind: &str, defaults: &HashMap) { + if deals.is_empty() { + eprintln!("search {kind}: no fit deal with a tape"); + return; + } + deals.sort_by_key(|d| d.deal.close_ms); + if let Some(horizon) = common_horizon_ms(&deals) { + clip_to_horizon(&mut deals, horizon); + } + let locked: HashSet = TICK_PARAMS + .iter() + .filter(|f| f.section != ParamSection::DeltaModifiers) + .map(|f| f.key.to_string()) + .collect(); + let restarts = std::env::var("MOON_TICKS_SEARCH_RESTARTS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10); + let held = HashMap::new(); + let params = SearchParams { + held: &held, + defaults, + kind, + vary_entry: false, + vary_exit: true, + locked: &locked, + restarts, + min_n: None, + seed: Some(1), + train_frac: 0.7, + max_passes: DEFAULT_MAX_PASSES, + model: ModelSettings::default(), + keep_corridor: false, + }; + let started = Instant::now(); + let answer = suggest(&deals, ¶ms, &SearchHandle::new()); + let elapsed = started.elapsed().as_millis(); + eprintln!( + "search {kind} strategy {} on core {}: {} deal(s), Delta Modifiers only, {restarts} restart(s), {elapsed} ms", + deals[0].deal.strategy_id, + deals[0].deal.core_name, + deals.len() + ); + let own = &deals[0].own; + let section: Vec<(&str, &String)> = TICK_PARAMS + .iter() + .filter(|f| f.section == ParamSection::DeltaModifiers) + .filter_map(|f| own.get(f.key).map(|v| (f.key, v))) + .collect(); + eprintln!(" own Delta Modifiers: {section:?}"); + // The whole sample, the strategies as they stand against the answer. + let whole = |values: &[(String, String)]| { + let (tally, _) = variant_tally(&deals, defaults, kind, values, params.model); + (tally.n, (tally.profit * 1000.0).round() / 1000.0) + }; + eprintln!( + " as they stand, whole sample (n, profit): {:?}", + whole(&[]) + ); + match answer { + Ok(found) => eprintln!( + " found {:?}\n whole sample {:?} · train n {} profit {:.3} · holdout {:?} · {:?}", + found.values, + whole(&found.values), + found.train.n, + found.train.profit, + found + .holdout + .map(|t| (t.n, (t.profit * 1000.0).round() / 1000.0)), + found.stats + ), + Err(miss) => eprintln!(" no answer: {miss:?}"), + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index b87e5339..47ac5dde 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -28,7 +28,7 @@ use super::tape::{PendingDeal, prepare_sample}; use crate::analytics::bg::ReadLane; use moon_core::db::tuner::threshold_search::SearchHandle; use moon_core::db::tuner::ticks::TICK_PARAMS; -use moon_core::db::tuner::ticks::params::ParamGroup; +use moon_core::db::tuner::ticks::params::{self, ParamGroup}; use moon_core::db::tuner::ticks::search::{ DEFAULT_MAX_PASSES, SearchMiss, SearchParams, check_corridors, suggest, train_len, variant_tally_by_deal, @@ -575,18 +575,15 @@ impl AnalyticsView { /// judged on a sample without the spikes such an order would catch, so the dialog says on /// how many trades it does. Over the rows the columns and the search replay, each on its /// strategy's current values as they take them, so "M" is the column's "by N"; and only - /// for a variant that moves an Entry field — one that leaves the corridor alone moves - /// nothing a warning could be about. + /// for a variant that moves an Entry field or `MaxModifier`, which caps the corridor too + /// (`params::moves_entry`) — one that leaves the corridor alone moves nothing a warning + /// could be about. fn ticks_change_warnings( &self, changes: &[(String, String)], cx: &Context, ) -> Vec { - let moves_entry = changes.iter().any(|(key, _)| { - TICK_PARAMS - .iter() - .any(|f| f.key == key && f.group == ParamGroup::Entry) - }); + let moves_entry = changes.iter().any(|(key, _)| params::moves_entry(key)); let Some(data) = self.ticks.data.data().filter(|_| moves_entry) else { return Vec::new(); }; From 711d9ad0bc7d29984db4ab11b6ca84c46ed5d6d4 Mon Sep 17 00:00:00 2001 From: guyverino Date: Fri, 25 Sep 2026 09:38:01 +0200 Subject: [PATCH 40/51] feat(tuner): search ranges from the live strategies, typed over in the 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). --- crates/moon-core/src/config/layout.rs | 12 +- .../src/config/layout/serde_compat.rs | 54 ++ crates/moon-core/src/config/layout/tests.rs | 73 +++ crates/moon-core/src/db/tuner/mod.rs | 4 +- .../moon-core/src/db/tuner/strategy_read.rs | 155 +++++- crates/moon-core/src/db/tuner/ticks/params.rs | 241 ++------- .../src/db/tuner/ticks/params/range.rs | 508 ++++++++++++++++++ .../src/db/tuner/ticks/params/range/tests.rs | 439 +++++++++++++++ .../src/db/tuner/ticks/params/tests.rs | 19 +- crates/moon-core/src/db/tuner/ticks/search.rs | 89 +-- .../db/tuner/ticks/search/closing/tests.rs | 2 + .../src/db/tuner/ticks/search/coupled.rs | 32 +- .../db/tuner/ticks/search/coupled/tests.rs | 9 +- .../src/db/tuner/ticks/search/deps.rs | 31 +- .../src/db/tuner/ticks/search/deps/tests.rs | 36 +- .../src/db/tuner/ticks/search/test_grids.rs | 166 ++++++ .../src/db/tuner/ticks/search/tests.rs | 57 +- .../db/tuner/ticks/tests/real_data/search.rs | 69 ++- .../src/analytics/tuner/ticks/cfg.rs | 24 + .../src/analytics/tuner/ticks/grid.rs | 187 +++---- .../src/analytics/tuner/ticks/load.rs | 96 +++- .../src/analytics/tuner/ticks/mod.rs | 76 ++- .../src/analytics/tuner/ticks/ranges.rs | 398 ++++++++++++++ .../src/analytics/tuner/ticks/ranges/tests.rs | 36 ++ .../src/analytics/tuner/ticks/rows.rs | 2 +- .../src/analytics/tuner/ticks/rows/tests.rs | 18 +- .../src/analytics/tuner/ticks/sections.rs | 36 ++ .../src/analytics/tuner/ticks/state.rs | 94 ++-- .../src/analytics/tuner/ticks/trade_pane.rs | 46 +- .../src/analytics/tuner/ticks/variants.rs | 144 +++-- crates/moon-ui-gpui/src/design.rs | 20 + locales/analytics.yml | 68 +++ 32 files changed, 2616 insertions(+), 625 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/params/range.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/params/range/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/test_grids.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/ranges.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/ranges/tests.rs diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index 1622c12c..9de42bf0 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -23,7 +23,8 @@ use serde_compat::{ de_connector_thickness, de_hvol_price_frame_pct, de_hvol_side, de_hvol_tf_s, de_hvol_width, de_lenient_chart_labels, de_lenient_false, de_lenient_graphics, de_lenient_map, de_lenient_seed, de_lenient_true, de_lenient_u32, de_marker_scale, - de_strategies_tree_text_step, de_table_sort_map, de_trade_history_style, de_trade_volume_alpha, + de_strategies_tree_text_step, de_table_sort_map, de_tick_ranges, de_trade_history_style, + de_trade_volume_alpha, }; pub use serde_compat::{de_lenient, de_lenient_bool}; @@ -655,6 +656,15 @@ pub struct TicksAxisLayout { /// the variant columns and the search run on; `None` = the axis default. The tape of an /// older trade cannot be fetched again, and one short tail cut every variant's exit at it. pub min_tail_s: Option, + /// Steps per field the automatic search ranges are cut into; `None` = the axis default + /// (`params::range::steps_of`). + #[serde(deserialize_with = "de_lenient")] + pub steps_per_param: Option, + /// The search ranges the user typed over the automatic ones, by field key — only fields with + /// a slot typed; a malformed entry is dropped alone. + #[serde(deserialize_with = "de_tick_ranges")] + pub ranges: + std::collections::BTreeMap, } /// Complete window layout. diff --git a/crates/moon-core/src/config/layout/serde_compat.rs b/crates/moon-core/src/config/layout/serde_compat.rs index c1206c21..636e5f22 100644 --- a/crates/moon-core/src/config/layout/serde_compat.rs +++ b/crates/moon-core/src/config/layout/serde_compat.rs @@ -473,3 +473,57 @@ where Some(Flag::Other(_)) | None => false, }) } + +/// Read the search ranges the user typed on the "Entry/Exit" axis, discarding only the malformed +/// entries: one hand-edited range must cost that field its range, not every setting of the axis. +/// +/// Args: +/// d: Serde deserializer positioned at the complete `ranges` value. +/// +/// Returns: +/// Every well-formed entry, or an empty map when the outer value is not a map. +/// +/// Errors: +/// Propagates only deserializer failures that cannot be consumed as ignored input. +pub(super) fn de_tick_ranges<'de, D>( + d: D, +) -> Result< + std::collections::BTreeMap, + D::Error, +> +where + D: serde::Deserializer<'de>, +{ + use crate::db::tuner::ticks::params::range::TickRange; + + /// One usable range or an ignored malformed entry. + #[derive(Deserialize)] + #[serde(untagged)] + enum Entry { + /// Exact range shape. + Valid(TickRange), + /// Any unsupported entry shape. + Other(serde::de::IgnoredAny), + } + + /// The expected map or an ignored malformed outer value. + #[derive(Deserialize)] + #[serde(untagged)] + enum Stored { + /// Field keys mapped to independently recoverable entries. + Map(std::collections::BTreeMap), + /// Any unsupported outer shape. + Other(serde::de::IgnoredAny), + } + + Ok(match Stored::deserialize(d)? { + Stored::Map(entries) => entries + .into_iter() + .filter_map(|(key, entry)| match entry { + Entry::Valid(range) if !range.is_auto() => Some((key, range)), + Entry::Valid(_) | Entry::Other(_) => None, + }) + .collect(), + Stored::Other(_) => std::collections::BTreeMap::new(), + }) +} diff --git a/crates/moon-core/src/config/layout/tests.rs b/crates/moon-core/src/config/layout/tests.rs index 2d6c18be..e3994d96 100644 --- a/crates/moon-core/src/config/layout/tests.rs +++ b/crates/moon-core/src/config/layout/tests.rs @@ -2370,3 +2370,76 @@ fn the_ticks_axis_settings_round_trip_and_never_cost_the_layout() { assert_eq!(broken.analytics_period.as_deref(), Some("p-cur-month")); assert_eq!(broken.analytics_ticks, None); } + +/// The search ranges typed on the Entry/Exit axis and its steps per field survive a restart; a +/// block written before they existed reads them empty; a malformed range costs that field its +/// range and nothing else of the axis, and a malformed step count only itself. +#[test] +fn the_ticks_axis_ranges_round_trip_and_a_bad_one_costs_only_itself() { + use crate::db::tuner::ticks::params::range::TickRange; + let saved = WindowLayout { + analytics_ticks: Some(TicksAxisLayout { + iters: Some(40), + steps_per_param: Some(30), + ranges: [ + ( + "SellPrice".to_string(), + TickRange { + from: Some(0.5), + to: Some(3.0), + step: None, + }, + ), + ( + "SellLevelCount".to_string(), + TickRange { + step: Some(1.0), + ..TickRange::default() + }, + ), + ] + .into(), + ..TicksAxisLayout::default() + }), + ..WindowLayout::default() + }; + let encoded = toml::to_string(&saved).expect("the layout must serialize"); + let decoded: WindowLayout = toml::from_str(&encoded).expect("its own output must load back"); + assert_eq!(decoded.analytics_ticks, saved.analytics_ticks); + + let before: WindowLayout = toml::from_str( + "[analytics_ticks] +iters = 40 +", + ) + .expect("a block without the ranges"); + let before = before.analytics_ticks.expect("the block"); + assert!(before.ranges.is_empty() && before.steps_per_param.is_none()); + + let bad = toml::from_str::( + "[analytics_ticks] +iters = 40 +steps_per_param = \"lots\" + [analytics_ticks.ranges.SellPrice] +from = 0.5 +to = 3 + [analytics_ticks.ranges.StopLoss] +from = \"low\" +", + ) + .expect("a malformed range must not reject the document") + .analytics_ticks + .expect("a malformed range must not cost the axis its block"); + assert_eq!(bad.iters, Some(40)); + assert_eq!(bad.steps_per_param, None); + assert_eq!( + bad.ranges.get("SellPrice"), + Some(&TickRange { + from: Some(0.5), + to: Some(3.0), + step: None, + }), + "an integer edge reads as a number" + ); + assert!(!bad.ranges.contains_key("StopLoss")); +} diff --git a/crates/moon-core/src/db/tuner/mod.rs b/crates/moon-core/src/db/tuner/mod.rs index 3b24e6f1..7f1baaa0 100644 --- a/crates/moon-core/src/db/tuner/mod.rs +++ b/crates/moon-core/src/db/tuner/mod.rs @@ -29,8 +29,8 @@ mod time; pub use fields::{FIELDS, FieldClass, FieldSpec, slot_type_for}; pub use strategy_read::{ - StratFilters, strategy_cores, strategy_current_values, strategy_current_values_opt, - strategy_filters, strategy_kinds, strategy_values_at, + LiveStrategy, StratFilters, live_strategies, strategy_cores, strategy_current_values, + strategy_current_values_opt, strategy_filters, strategy_kinds, strategy_values_at, }; pub use time::{ SliderProfiles, TimeAxes, TimeSuggest, TimeWindow, format_week_span, format_working_time, diff --git a/crates/moon-core/src/db/tuner/strategy_read.rs b/crates/moon-core/src/db/tuner/strategy_read.rs index 388d60c6..b14a3289 100644 --- a/crates/moon-core/src/db/tuner/strategy_read.rs +++ b/crates/moon-core/src/db/tuner/strategy_read.rs @@ -289,30 +289,147 @@ fn flatten_values(raw: &str, keys: &[String]) -> Option s.clone(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => if *b { "YES" } else { "NO" }.to_string(), - // A list-valued field (a coin list spelled as a JSON array) flattens to the - // comma form the callers parse. Dropping it here while the SQL column reads it - // is what makes one screen count coins the other cannot see. - serde_json::Value::Array(items) => items - .iter() - .filter_map(|i| match i { - serde_json::Value::String(s) => Some(s.clone()), - serde_json::Value::Number(n) => Some(n.to_string()), - _ => None, - }) - .collect::>() - .join(","), - _ => continue, + let Some(text) = map.get(key).and_then(value_text) else { + continue; }; - out.insert(key.clone(), s); + out.insert(key.clone(), text); } Some(out) } +/// One `raw_json` value in strategy format: a string as is, a number in its shortest form, a +/// boolean as `YES`/`NO`, a list in the comma form; `None` for anything else. +fn value_text(v: &serde_json::Value) -> Option { + Some(match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => if *b { "YES" } else { "NO" }.to_string(), + // A list-valued field (a coin list spelled as a JSON array) flattens to the comma form + // the callers parse. Dropping it here while the SQL column reads it is what makes one + // screen count coins the other cannot see. + serde_json::Value::Array(items) => items + .iter() + .filter_map(|i| match i { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .collect::>() + .join(","), + _ => return None, + }) +} + +/// One live strategy as the search's automatic ranges read it +/// (`ticks::params::range::Population`). +#[derive(Clone, Debug, PartialEq)] +pub struct LiveStrategy { + /// `SignalType` — the spelling a deal's kind has (`strategy_kinds`), not the `kind` column, + /// which spells some kinds otherwise (`PumpDetection` for `PumpsDetection`). + pub kind: String, + /// The asked fields the strategy's dump holds, by LOWERCASE name, in strategy format. + pub values: std::collections::HashMap, +} + +/// What [`live_strategies`] last read, and the state of the file it read it from. +struct LiveCache { + signature: (i64, i64, i64), + keys: Vec, + strategies: std::sync::Arc>, +} + +static LIVE_CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Every live strategy of every core — the current version of each one not deleted — with the +/// fields `keys` names (matched without regard to case), one per distinct content: a strategy +/// copied onto many cores is one strategy (1 423 live, 1 108 distinct here, 2026-09-25). +/// +/// Read once per state of the file (the count of heads, the newest head update and the newest +/// version): the axis loads on every move of the report, the strategies change far more rarely. +/// Measured 2026-09-25 on this machine: 1 423 heads, 4.3 MB of JSON, ~60 ms in Python. +/// +/// Returns: +/// The strategies, empty when the file is absent or will not read. +pub fn live_strategies(keys: &[String]) -> std::sync::Arc> { + let Some(conn) = open_strategies_ro() else { + return std::sync::Arc::default(); + }; + let signature = conn + .query_row( + "SELECT (SELECT count(*) FROM strategies), + (SELECT coalesce(max(updated_ms), 0) FROM strategies), + (SELECT coalesce(max(id), 0) FROM strategy_versions)", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap_or((-1, -1, -1)); + let mut cache = LIVE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(hit) = cache + .as_ref() + .filter(|c| signature.0 >= 0 && c.signature == signature && c.keys == keys) + { + return std::sync::Arc::clone(&hit.strategies); + } + let started = std::time::Instant::now(); + let wanted: std::collections::HashSet = + keys.iter().map(|k| k.to_ascii_lowercase()).collect(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut out: Vec = Vec::new(); + let read = conn + .prepare( + "SELECT s.content_hash, v.raw_json FROM strategies s + JOIN strategy_versions v + ON v.core_uid = s.core_uid AND v.strategy_id = s.strategy_id + WHERE s.deleted = 0 AND v.valid_to IS NULL", + ) + .and_then(|mut stmt| { + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; + for row in rows { + let (hash, raw) = row?; + if !seen.insert(hash) { + continue; + } + let Ok(serde_json::Value::Object(map)) = serde_json::from_str(&raw) else { + continue; + }; + let kind = map + .get("SignalType") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let values = map + .iter() + .filter_map(|(key, value)| { + let lower = key.to_ascii_lowercase(); + wanted + .contains(&lower) + .then(|| value_text(value).map(|text| (lower, text))) + .flatten() + }) + .collect(); + out.push(LiveStrategy { kind, values }); + } + Ok(()) + }); + if let Err(error) = read { + log::warn!("[x] tuner: live strategies unreadable: {error}"); + return std::sync::Arc::default(); + } + log::info!( + target: crate::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks ranges: read {} distinct live strategies in {} ms", + out.len(), + started.elapsed().as_millis() + ); + let strategies = std::sync::Arc::new(out); + *cache = Some(LiveCache { + signature, + keys: keys.to_vec(), + strategies: std::sync::Arc::clone(&strategies), + }); + strategies +} + /// Threshold parameters of the SELECTED strategy for tuner fields. /// The source is the current strategies.sqlite version (raw_json normalized with schema /// defaults). `defaults` contains schema defaults (lowercase name -> number): a value EQUAL diff --git a/crates/moon-core/src/db/tuner/ticks/params.rs b/crates/moon-core/src/db/tuner/ticks/params.rs index e17d4810..8efa96fd 100644 --- a/crates/moon-core/src/db/tuner/ticks/params.rs +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -70,12 +70,12 @@ impl ParamSection { } } -/// How a parameter is typed and, for the search, which values it may take. +/// How a parameter is typed. A number's candidate values are not fixed here: they come from +/// what the live strategies hold of it, or from the range the user typed ([`range`]). #[derive(Clone, Copy, Debug, PartialEq)] pub enum ParamKind { - /// A number; `grid` is the search's discrete candidate set (§5.1 of the spec — a proposal - /// to narrow to practice). - Num { grid: &'static [f64] }, + /// A number. + Num, /// `YES` / `NO`. Bool, /// One of a fixed spelling set. @@ -111,124 +111,13 @@ const NOT_SELL_PRICE: &[&str] = &[ ]; const ANY: &[&str] = &[]; -/// The corridor's two fields, `MShotPrice` and `MShotPriceMin`: every 0.05 of a per cent from -/// 0.05 to 8 (LinKvo, 2026-09-24) — a strategy's own 1.7 is a step, not a snap to 1.75. -const GRID_PRICE: &[f64] = &twentieths::<160>(); -const GRID_PRICE_MIN: &[f64] = GRID_PRICE; - -/// `5/100, 10/100, … 5·N/100`: each value divided rather than summed, so 1.7 is exactly the -/// `1.7` a strategy spells, with no accumulated rounding. -const fn twentieths() -> [f64; N] { - let mut out = [0.0; N]; - let mut i = 0; - while i < N { - out[i] = ((i + 1) * 5) as f64 / 100.0; - i += 1; - } - out -} -const GRID_WAIT_S: &[f64] = &[0.0, 0.1, 0.3, 0.5, 1.0, 2.0, 5.0]; -const GRID_ADJUST: &[f64] = &[ - -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, - 1.6, 1.8, 2.0, -]; -/// The `MShotAdd*` modifiers, per cent per one per cent of the delta. Fine near zero — the -/// 24-hour deltas run to tens of per cent, and their live coefficients sit at 0.001–0.002 — then -/// a 0.05 step to 1.0 (the developer's call, 2026-09-24): the old 0.2 ceiling could not reach a -/// live `MShotAddMarkDelta` of 0.5. -const GRID_ADD: &[f64] = &[ - 0.0, 0.001, 0.002, 0.005, 0.01, 0.02, 0.03, 0.04, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, - 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0, -]; -/// `MShotAddDistance`, per cent: finer below 50 — a live strategy's 10 sat between the old 0 -/// and 25 (2026-09-24). -const GRID_DISTANCE: &[f64] = &[ - 0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0, 75.0, 100.0, 150.0, 200.0, -]; -/// Measured against the 1 713 live strategies that set it (2026-09-22): median 1 %, and 300 of -/// them sit outside 0.2…5 — up to 11 % — so the tail is covered rather than clipped. -const GRID_SELL_PRICE: &[f64] = &[ - 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, - 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, -]; -const GRID_SELL_DELAY_MS: &[f64] = &[0.0, 100.0, 250.0, 500.0, 1000.0]; -/// `HookSellLevel`, per cent of the detect depth: 100 sells at the top the move started from, -/// 50 in the middle. The live strategies on this machine use 50 and 100. -const GRID_HOOK_LEVEL: &[f64] = &[ - 10.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 75.0, 80.0, 90.0, 100.0, -]; -const GRID_PD_TIMER_S: &[f64] = &[ - 0.0, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0, -]; -/// 1 865 live strategies set it; 121 of them below 5 %, which the old floor cut off. -const GRID_PD_PCT: &[f64] = &[ - 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 70.0, - 80.0, 90.0, 100.0, -]; -const GRID_PD_DELAY_S: &[f64] = &[0.0, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 30.0, 60.0]; -const GRID_DROP: &[f64] = &[ - -1.0, -0.5, -0.2, -0.1, 0.0, 0.01, 0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, -]; -const GRID_SL_DELAY_S: &[f64] = &[0.0, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0]; -const GRID_SL_TIME_S: &[f64] = &[0.0, 60.0, 300.0, 900.0, 1800.0, 3600.0, 7200.0]; -const GRID_SL_COUNT: &[f64] = &[0.0, 1.0, 2.0, 3.0, 5.0, 10.0]; -/// Live values run to −15 (29 of 1 869 strategies sit outside the old −10 floor). -const GRID_STOP: &[f64] = &[ - -15.0, -12.0, -10.0, -7.0, -5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.75, -0.5, -0.3, -0.2, - -0.1, -]; -const GRID_STOP_DELAY_S: &[f64] = &[0.0, 1.0, 2.0, 4.0, 6.0, 10.0, 20.0, 30.0]; -/// `TimeToSwitch2Stop` / `TimeToSwitchStop3`, whole seconds: the live ladders switch after 0–5 s -/// (78 strategies with `UseSecondStop`, 2026-09-24); the rest reach the "stop by time" use. -const GRID_SWITCH_S: &[f64] = &[ - 0.0, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0, -]; -/// `PriceToSwitch2Stop` / `PriceToSwitchStop3`, per cent off the buy: live 0.3, 0.5 and 1.5. -const GRID_SWITCH_PCT: &[f64] = &[0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.3, 1.5, 2.0, 3.0, 5.0]; -/// `SecondStopLoss` / `StopLoss3`, per cent off the buy: a break-even step lives just over zero -/// (live 0.25, 0.4, 0.8), a stop by time below it. -const GRID_STEP_LEVEL: &[f64] = &[ - -3.0, -2.0, -1.0, -0.5, -0.2, 0.0, 0.1, 0.2, 0.25, 0.3, 0.4, 0.5, 0.8, 1.0, 1.5, 2.0, -]; -/// `TrailingPercent`, negative: live −0.1 to −4 among the 81 strategies with `UseTrailing`. -const GRID_TRAILING: &[f64] = &[ - -10.0, -5.0, -4.0, -3.0, -2.0, -1.8, -1.5, -1.0, -0.8, -0.5, -0.3, -0.2, -0.1, -]; -/// `TrailingEMA`, ticks: live 0, 2 and 4. -const GRID_TRAILING_EMA: &[f64] = &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; -/// `TakeProfit` of the trailing, per cent off the buy: live 1, 2, 2.5 and 5. -const GRID_TAKE_PROFIT: &[f64] = &[0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 5.0, 10.0]; -/// `SellModifier`, per cent of the sell's price per one per cent of the summed deltas: live 0.03 -/// to 1.5 among the 201 strategies of 1 423 that set it (2026-09-25; 0.5 at 114 of them). Below -/// zero a volatile coin's sell comes nearer the entry, which the core allows. -const GRID_SELL_MODIFIER: &[f64] = &[ - -0.5, -0.3, -0.2, -0.1, -0.05, 0.0, 0.03, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.75, 1.0, - 1.5, -]; -/// `StopLossModifier`: live 0.2 at 139 of 150 strategies, −0.3 and −0.1 at the rest. -const GRID_STOP_MODIFIER: &[f64] = &[ - -0.5, -0.3, -0.2, -0.1, -0.05, 0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 1.0, -]; -/// `MaxModifier`, per cent of summed deltas; 0 caps nothing. Live 10 to 1 000 (30 at 47 of 127); -/// the small steps are what lets a cap bite on a quiet coin. -const GRID_MAX_MODIFIER: &[f64] = &[ - 0.0, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 100.0, 130.0, 200.0, 1000.0, -]; -/// The `Add*` terms of the Delta Modifiers tab, per one per cent of their delta: live from 0.002 -/// (`Add3hDelta`) to 3 (`Add1minDelta`), none below zero (2026-09-25). The sum is taken as a -/// magnitude, so a single term's sign moves nothing. -const GRID_DELTA_ADD: &[f64] = &[ - 0.0, 0.001, 0.002, 0.005, 0.01, 0.02, 0.03, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0, - 1.5, 2.0, 3.0, -]; - /// Every parameter of the axis, grid order: the Entry group first, then Exit. pub const TICK_PARAMS: &[TickParam] = &[ TickParam { key: "MShotPrice", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_PRICE }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -236,9 +125,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotPriceMin", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { - grid: GRID_PRICE_MIN, - }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -254,7 +141,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotRaiseWait", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_WAIT_S }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -262,7 +149,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotReplaceDelay", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_WAIT_S }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -286,7 +173,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAddHourlyDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -294,7 +181,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAdd3hDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -302,7 +189,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAdd15minDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -310,7 +197,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAdd5minDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -318,7 +205,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAdd1minDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -326,7 +213,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAdd24hDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -334,7 +221,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAddMarkDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -342,7 +229,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAddMarketDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -350,7 +237,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAddBTCDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -358,7 +245,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAddBTC5mDelta", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -366,7 +253,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAddPriceBug", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADD }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -374,9 +261,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotAddDistance", group: ParamGroup::Entry, section: ParamSection::StrategySettings, - kind: ParamKind::Num { - grid: GRID_DISTANCE, - }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -384,9 +269,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "SellPrice", group: ParamGroup::Exit, section: ParamSection::SellOrder, - kind: ParamKind::Num { - grid: GRID_SELL_PRICE, - }, + kind: ParamKind::Num, kinds: ANY, // A MoonHook carries no `SellPrice` at all — `HookSellLevel` below is its take — and a // Spread's take is the spread it detected, whatever the field says. @@ -404,7 +287,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "MShotSellPriceAdjust", group: ParamGroup::Exit, section: ParamSection::StrategySettings, - kind: ParamKind::Num { grid: GRID_ADJUST }, + kind: ParamKind::Num, kinds: MSHOT, not_kinds: &[], }, @@ -412,9 +295,7 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "HookSellLevel", group: ParamGroup::Exit, section: ParamSection::StrategySettings, - kind: ParamKind::Num { - grid: GRID_HOOK_LEVEL, - }, + kind: ParamKind::Num, kinds: HOOK, not_kinds: &[], }, @@ -422,29 +303,23 @@ pub const TICK_PARAMS: &[TickParam] = &[ key: "SellDelay", group: ParamGroup::Exit, section: ParamSection::SellOrder, - kind: ParamKind::Num { - grid: GRID_SELL_DELAY_MS, - }, + kind: ParamKind::Num, kinds: ANY, not_kinds: &[], }, - exit_num("PriceDownTimer", ParamSection::SellOrder, GRID_PD_TIMER_S), - exit_num("PriceDownPercent", ParamSection::SellOrder, GRID_PD_PCT), - exit_num("PriceDownDelay", ParamSection::SellOrder, GRID_PD_DELAY_S), + exit_num("PriceDownTimer", ParamSection::SellOrder), + exit_num("PriceDownPercent", ParamSection::SellOrder), + exit_num("PriceDownDelay", ParamSection::SellOrder), exit_bool("PriceDownRelative", ParamSection::SellOrder), - exit_num("PriceDownAllowedDrop", ParamSection::SellOrder, GRID_DROP), - exit_num("SellLevelDelay", ParamSection::SellOrder, GRID_SL_DELAY_S), - exit_num( - "SellLevelDelayNext", - ParamSection::SellOrder, - GRID_SL_DELAY_S, - ), - exit_num("SellLevelTime", ParamSection::SellOrder, GRID_SL_TIME_S), - exit_num("SellLevelCount", ParamSection::SellOrder, GRID_SL_COUNT), - exit_num("SellLevelAdjust", ParamSection::SellOrder, GRID_DROP), + exit_num("PriceDownAllowedDrop", ParamSection::SellOrder), + exit_num("SellLevelDelay", ParamSection::SellOrder), + exit_num("SellLevelDelayNext", ParamSection::SellOrder), + exit_num("SellLevelTime", ParamSection::SellOrder), + exit_num("SellLevelCount", ParamSection::SellOrder), + exit_num("SellLevelAdjust", ParamSection::SellOrder), exit_bool("SellLevelRelative", ParamSection::SellOrder), - exit_num("SellLevelAllowedDrop", ParamSection::SellOrder, GRID_DROP), - exit_num("SellLevelWorkTime", ParamSection::SellOrder, GRID_SL_TIME_S), + exit_num("SellLevelAllowedDrop", ParamSection::SellOrder), + exit_num("SellLevelWorkTime", ParamSection::SellOrder), // The Stops section in the strategy window's order. `FastStopLoss` is read with the strategy's // value — the trigger hangs on it — but is no knob; `UseMarketOrder` is read by nothing (the // verdict tells a market stop by the fact's own reason, `StopLoss Market Sell`); nor are the panic sell's execution fields (`StopLossSpread`, @@ -460,42 +335,32 @@ pub const TICK_PARAMS: &[TickParam] = &[ kinds: ANY, not_kinds: &[], }, - exit_num("StopLossDelay", ParamSection::Stops, GRID_STOP_DELAY_S), - exit_num("StopLoss", ParamSection::Stops, GRID_STOP), + exit_num("StopLossDelay", ParamSection::Stops), + exit_num("StopLoss", ParamSection::Stops), exit_bool("UseSecondStop", ParamSection::Stops), - exit_num("TimeToSwitch2Stop", ParamSection::Stops, GRID_SWITCH_S), - exit_num("PriceToSwitch2Stop", ParamSection::Stops, GRID_SWITCH_PCT), - exit_num("SecondStopLoss", ParamSection::Stops, GRID_STEP_LEVEL), + exit_num("TimeToSwitch2Stop", ParamSection::Stops), + exit_num("PriceToSwitch2Stop", ParamSection::Stops), + exit_num("SecondStopLoss", ParamSection::Stops), exit_bool("UseStopLoss3", ParamSection::Stops), - exit_num("TimeToSwitchStop3", ParamSection::Stops, GRID_SWITCH_S), - exit_num("PriceToSwitchStop3", ParamSection::Stops, GRID_SWITCH_PCT), - exit_num("StopLoss3", ParamSection::Stops, GRID_STEP_LEVEL), + exit_num("TimeToSwitchStop3", ParamSection::Stops), + exit_num("PriceToSwitchStop3", ParamSection::Stops), + exit_num("StopLoss3", ParamSection::Stops), exit_bool("UseTrailing", ParamSection::Stops), - exit_num("TrailingPercent", ParamSection::Stops, GRID_TRAILING), - exit_num("TrailingEMA", ParamSection::Stops, GRID_TRAILING_EMA), + exit_num("TrailingPercent", ParamSection::Stops), + exit_num("TrailingEMA", ParamSection::Stops), exit_bool("UseTakeProfit", ParamSection::Stops), - exit_num("TakeProfit", ParamSection::Stops, GRID_TAKE_PROFIT), + exit_num("TakeProfit", ParamSection::Stops), // The Delta Modifiers section: one capped sum of the trade's deltas, spent on the sell and on // the stop (`exit::delta_mods`). It is a product, and the search walks it as one // (`search::coupled`). `BuyModifier` and `DetectModifier` move the entry and the detect, // which the model takes from the fact for every kind but MoonShot, whose core ignores them. - exit_num( - "SellModifier", - ParamSection::DeltaModifiers, - GRID_SELL_MODIFIER, - ), - exit_num( - "StopLossModifier", - ParamSection::DeltaModifiers, - GRID_STOP_MODIFIER, - ), + exit_num("SellModifier", ParamSection::DeltaModifiers), + exit_num("StopLossModifier", ParamSection::DeltaModifiers), TickParam { key: "MaxModifier", group: ParamGroup::Exit, section: ParamSection::DeltaModifiers, - kind: ParamKind::Num { - grid: GRID_MAX_MODIFIER, - }, + kind: ParamKind::Num, kinds: ANY, // One field, two families: a MoonShot's cap also bounds its `MShotAdd*` corridor // (`mshot_params`), so turning it in an Exit search would move the entry the search @@ -521,16 +386,16 @@ pub const TICK_PARAMS: &[TickParam] = &[ /// An `Add*` term of the Delta Modifiers section. const fn delta_add(key: &'static str) -> TickParam { - exit_num(key, ParamSection::DeltaModifiers, GRID_DELTA_ADD) + exit_num(key, ParamSection::DeltaModifiers) } /// A numeric field of the Exit group every kind understands. -const fn exit_num(key: &'static str, section: ParamSection, grid: &'static [f64]) -> TickParam { +const fn exit_num(key: &'static str, section: ParamSection) -> TickParam { TickParam { key, group: ParamGroup::Exit, section, - kind: ParamKind::Num { grid }, + kind: ParamKind::Num, kinds: ANY, not_kinds: &[], } @@ -846,5 +711,7 @@ pub(super) fn unmodelled_rule(v: &StrategyValues<'_>) -> Option } } +pub mod range; + #[cfg(test)] mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/params/range.rs b/crates/moon-core/src/db/tuner/ticks/params/range.rs new file mode 100644 index 00000000..c8ad182d --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/params/range.rs @@ -0,0 +1,508 @@ +//! The candidate values of the search's number fields. They are not a ladder kept by hand per +//! field any more: they come from what the live strategies hold of each field (the developer's +//! call, 2026-09-25 — a table of per-field rules is one more place a new field has to be +//! remembered in, and one that goes stale as the strategies move). +//! +//! A field's automatic range runs from the 5th to the 95th percentile of the values the live +//! strategies of the scope's kinds set ([`Population`]) — each strategy once however many cores +//! carry it, only where the field is in effect (`StopLoss` stays in the dump of a strategy with +//! `UseStopLoss = NO` and says nothing there: +50 on this machine, 2026-09-25), only where it +//! differs from the schema default (the core leaves a field at its default out of the dump, so +//! a tail cut over every strategy would cut a field 5 % of them set down to its default) — +//! widened to take the default and the selected strategies' own values in ([`field_span`]). +//! It is cut into about `steps` equal steps, the step rounded UP to 1, 2, 2.5 or 5 times a +//! power of ten and never finer than the finest digit the values carry ([`FieldSpan::auto`]); +//! the selected strategies' own values join the grid exactly, so restart 0 stands on the +//! strategy and "leave it" is an answer the search can give. +//! +//! The user may type any of from, to and step over the automatic ones ([`TickRange`]); a slot +//! left empty stays automatic ([`resolve`]). Every value is rounded to the field's precision and +//! the grid deduplicated, so a step finer than the field holds yields each value once: a step +//! of 0.3 on a whole-number field gives 1, 2, 3, not 0, 0, 1, 1, 1, 2 — a repeated value is a +//! point the search would replay the sample for again, and a restart's shift of a few steps +//! that stands still. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use super::{ParamKind, TICK_PARAMS, TickParam, parse_num}; +use crate::db::tuner::LiveStrategy; +use crate::feed::strategy_deps::FieldDeps; + +/// Steps per field when the search settings do not say. +pub const DEFAULT_STEPS: u32 = 20; +/// The fewest steps per field the setting takes. +pub const MIN_STEPS: u32 = 3; +/// The most steps per field the setting takes — and the most points a typed range may give one +/// field: the search scans every point of a field on every pass, so its time grows with the sum. +pub const MAX_STEPS: u32 = 200; + +/// Fewer values than this for a field among the scope's kinds, and its range is taken over every +/// kind: the percentiles of a handful of values are the values (Drops — 5 strategies here). +const MIN_POPULATION: usize = 20; +/// The share cut off each end of the population. +const TAIL: f64 = 0.05; +/// The finest precision a field is gridded at, decimals. +const MAX_DECIMALS: u32 = 6; + +/// The steps-per-field setting as the search takes it: the default when unset, within +/// [`MIN_STEPS`]..=[`MAX_STEPS`]. +pub fn steps_of(setting: Option) -> u32 { + setting.unwrap_or(DEFAULT_STEPS).clamp(MIN_STEPS, MAX_STEPS) +} + +/// The user's own edges of one field's range. A slot left `None` stays automatic. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TickRange { + pub from: Option, + pub to: Option, + pub step: Option, +} + +impl TickRange { + /// Whether every slot is automatic. + pub fn is_auto(&self) -> bool { + self.from.is_none() && self.to.is_none() && self.step.is_none() + } + + /// The range with every slot that is not a finite number emptied — a hand edit of the saved + /// layout reads as automatic there rather than as a range of NaN. + fn finite(self) -> Self { + let keep = |v: Option| v.filter(|v| v.is_finite()); + Self { + from: keep(self.from), + to: keep(self.to), + step: keep(self.step), + } + } +} + +/// What the live strategies hold of each number field, by strategy kind (`SignalType`, the +/// spelling a deal's kind has). A value counts only where the field is in effect under the +/// strategy's own switches and differs from the schema default. +#[derive(Clone, Debug, Default)] +pub struct Population { + by_kind: HashMap>>, +} + +impl Population { + /// Args: + /// strategies: The live strategies, one per distinct content + /// ([`crate::db::tuner::live_strategies`]). + /// defaults: Schema defaults, lowercase key → number. + /// deps: The Strategies window's field rules — which switch a field hangs on. + pub fn of( + strategies: &[LiveStrategy], + defaults: &HashMap, + deps: &FieldDeps, + ) -> Self { + let mut by_kind: HashMap>> = HashMap::new(); + for strategy in strategies { + let values = + crate::db::tuner::ticks::search::strategy_values(&strategy.values, defaults); + for field in number_fields() { + let lower = field.key.to_ascii_lowercase(); + let Some(value) = strategy.values.get(&lower).and_then(|s| parse_num(s)) else { + continue; + }; + if defaults.get(&lower).is_some_and(|d| same(*d, value)) + || !deps.field_active(field.key, &values) + { + continue; + } + by_kind + .entry(strategy.kind.clone()) + .or_default() + .entry(field.key) + .or_default() + .push(value); + } + } + Self { by_kind } + } + + /// The values of `key` among the strategies of `kinds` — or of every kind, when those hold + /// fewer than [`MIN_POPULATION`] of them or `kinds` is empty. + pub fn values(&self, kinds: &[String], key: &str) -> Vec { + let of = |map: &HashMap<&'static str, Vec>| map.get(key).cloned().unwrap_or_default(); + let scoped: Vec = kinds + .iter() + .filter_map(|kind| self.by_kind.get(kind)) + .flat_map(of) + .collect(); + if !kinds.is_empty() && scoped.len() >= MIN_POPULATION { + return scoped; + } + self.by_kind.values().flat_map(of).collect() + } +} + +/// The number knobs of the axis, each once. +fn number_fields() -> impl Iterator { + TICK_PARAMS + .iter() + .enumerate() + .filter(|(i, f)| { + f.kind == ParamKind::Num && !TICK_PARAMS[..*i].iter().any(|g| g.key == f.key) + }) + .map(|(_, f)| f) +} + +/// Where one field's automatic range stands before it is cut into steps: the population's tails +/// widened to the default and the selected strategies' values, and the precision the values +/// carry. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldSpan { + pub lo: f64, + pub hi: f64, + /// The finest decimal digit among the values, the default and the selected ones. + pub decimals: u32, + /// The selected strategies' own values, sorted and distinct — each joins the grid exactly. + pub selected: Vec, +} + +/// One field's span, or `None` when nothing is known of it — no strategy sets it, it has no +/// default and no selected strategy holds it. +/// +/// Args: +/// population: The field's values among the live strategies ([`Population::values`]). +/// default: The schema default. +/// selected: The selected strategies' values, a strategy that leaves it out at the default. +pub fn field_span(population: &[f64], default: Option, selected: &[f64]) -> Option { + let mut sorted: Vec = population + .iter() + .copied() + .filter(|v| v.is_finite()) + .collect(); + sorted.sort_by(f64::total_cmp); + let mut own: Vec = selected.iter().copied().filter(|v| v.is_finite()).collect(); + own.sort_by(f64::total_cmp); + own.dedup_by(|a, b| same(*a, *b)); + let default = default.filter(|v| v.is_finite()); + let tails = (!sorted.is_empty()) + .then(|| [percentile(&sorted, TAIL), percentile(&sorted, 1.0 - TAIL)]) + .into_iter() + .flatten(); + let anchors: Vec = tails.chain(default).chain(own.iter().copied()).collect(); + let lo = anchors.iter().copied().reduce(f64::min)?; + let hi = anchors.iter().copied().reduce(f64::max)?; + let decimals = sorted + .iter() + .chain(&own) + .chain(&default) + .map(|v| decimals_of(*v)) + .max() + .unwrap_or(0); + Some(FieldSpan { + lo, + hi, + decimals, + selected: own, + }) +} + +/// The value at quantile `q` of an ascending, non-empty slice, by the nearest rank. +fn percentile(sorted: &[f64], q: f64) -> f64 { + let at = (q * (sorted.len() - 1) as f64).round() as usize; + sorted[at.min(sorted.len() - 1)] +} + +/// The edges and the step a field's grid is shown with and cut by; `step` 0 is a one-point grid. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Shown { + pub from: f64, + pub to: f64, + pub step: f64, +} + +impl FieldSpan { + /// The automatic range: the span cut into about `steps` steps of a round size, the edges + /// moved out to a multiple of it — but never across zero. A span all below zero ends where + /// it ends rather than at 0, and one all above starts where it starts: 0 is a meaning of its + /// own for the fields that have a sign — no stop armed (`exit::stops::stop_pct`), a corridor + /// of nothing — which no strategy of the span holds. The grid between stays on the multiples + /// of the step either way ([`cut`]), the edge that is not one tried beside them. + pub fn auto(&self, steps: u32) -> Shown { + let steps = steps.clamp(MIN_STEPS, MAX_STEPS); + if self.hi - self.lo <= f64::EPSILON * self.hi.abs().max(1.0) { + return Shown { + from: self.lo, + to: self.hi, + step: 0.0, + }; + } + let step = round_step((self.hi - self.lo) / f64::from(steps - 1), self.decimals); + let places = self.decimals.max(decimals_of(step)); + // A floor off by the division's noise would take a step too many ([`near`]). + let (lo_q, hi_q) = (self.lo / step, self.hi / step); + let mut from = snap((lo_q + near(lo_q)).floor() * step, places); + let mut to = snap((hi_q - near(hi_q)).ceil() * step, places); + if self.lo > 0.0 && from <= 0.0 { + from = self.lo; + } + if self.hi < 0.0 && to >= 0.0 { + to = self.hi; + } + Shown { from, to, step } + } +} + +/// The smallest round step — 1, 2, 2.5 or 5 times a power of ten — at least `raw` and a whole +/// multiple of the field's precision, so every point of the grid is a value the field holds. +fn round_step(raw: f64, decimals: u32) -> f64 { + let quantum = quantum(decimals); + let raw = raw.max(quantum); + let mut power = raw.log10().floor() as i32 - 1; + // Past ten decades up there is always 10^power itself, a multiple of any quantum ≤ 1. + for _ in 0..12 { + for mantissa in [1.0, 2.0, 2.5, 5.0] { + let candidate = snap(mantissa * 10f64.powi(power), MAX_DECIMALS + 4); + let multiple = candidate / quantum; + if candidate >= raw * (1.0 - 1e-9) && (multiple - multiple.round()).abs() < 1e-6 { + return candidate; + } + } + power += 1; + } + raw +} + +/// Why a typed range is not used; the search then takes the automatic one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RangeError { + /// "from" above "to". + Inverted, + /// A step of zero or below over a range wider than a point. + BadStep, + /// More points than [`MAX_STEPS`]. + TooMany, + /// A step typed without "from" and "to" on a field nothing is known of: there is no range to + /// cut it over. + NoEdges, +} + +/// One field's grid as the search takes it. +#[derive(Clone, Debug, PartialEq)] +pub struct Resolved { + /// The edges and the step the grid was cut by, `None` when nothing is known of the field. + pub shown: Option, + /// The candidate values, ascending and distinct. + pub points: Arc<[f64]>, + /// Why the typed range was set aside for the automatic one. + pub error: Option, +} + +/// One field's grid: the typed slots over the automatic ones. +/// +/// A typed "from" or "to" without a typed step is cut by a round step for `steps` over the new +/// span. The values are rounded to the field's precision — whole numbers for an integer field +/// of the schema, else the finest digit among the values and the typed slots — and the grid +/// deduplicated. The selected strategies' own values inside the range join it. +/// +/// Args: +/// span: The field's automatic span, `None` when nothing is known of it. +/// typed: The user's slots. +/// integer: Whether the schema types the field as an integer. +/// steps: Steps per field ([`steps_of`]). +pub fn resolve(span: Option<&FieldSpan>, typed: &TickRange, integer: bool, steps: u32) -> Resolved { + let steps = steps.clamp(MIN_STEPS, MAX_STEPS); + let typed = typed.finite(); + let auto = span.map(|s| s.auto(steps)); + let selected: &[f64] = span.map_or(&[], |s| s.selected.as_slice()); + let data_places = if integer { + 0 + } else { + span.map_or(0, |s| s.decimals) + }; + let automatic = |error: Option| Resolved { + shown: auto, + points: auto + .map_or_else( + || selected.to_vec(), + |a| cut(a, data_places, selected, None, true), + ) + .into(), + error, + }; + if typed.is_auto() { + return automatic(None); + } + let places = if integer { + 0 + } else { + [typed.from, typed.to, typed.step] + .into_iter() + .flatten() + .map(decimals_of) + .fold(data_places, u32::max) + }; + let (from, to) = match ( + typed.from.or(auto.map(|a| a.from)), + typed.to.or(auto.map(|a| a.to)), + ) { + (Some(from), Some(to)) => (from, to), + (Some(one), None) | (None, Some(one)) => (one, one), + // Only a typed step is left, and nothing to cut it over. + (None, None) => return automatic(Some(RangeError::NoEdges)), + }; + if from > to { + return automatic(Some(RangeError::Inverted)); + } + let step = if to - from <= f64::EPSILON * to.abs().max(1.0) { + 0.0 + } else { + match (typed.step, auto) { + (Some(step), _) => step, + (None, Some(a)) if typed.from.is_none() && typed.to.is_none() => a.step, + _ => round_step((to - from) / f64::from(steps - 1), places), + } + }; + if step <= 0.0 && to > from { + return automatic(Some(RangeError::BadStep)); + } + let shown = Shown { from, to, step }; + // A typed "from" anchors the steps; without one the automatic "from" stands and the grid + // keeps to the multiples of the step, as the automatic one does. + let on_multiples = typed.from.is_none(); + // Counted before anything is cut: a step typed a million times too fine is refused, not + // allocated. + if step > 0.0 && (to - from) / step > f64::from(MAX_STEPS) + 2.0 { + return automatic(Some(RangeError::TooMany)); + } + if cut(shown, places, &[], None, on_multiples).len() > MAX_STEPS as usize { + return automatic(Some(RangeError::TooMany)); + } + Resolved { + shown: Some(shown), + points: cut(shown, places, selected, Some((from, to)), on_multiples).into(), + error: None, + } +} + +/// A tolerance for a quotient of the range by its step, scaled to it: at 1000 over a step of +/// 1e-5 the division itself is off by more than any fixed epsilon. +fn near(q: f64) -> f64 { + 1e-9 * q.abs().max(1.0) +} + +/// The points of a range, rounded to `places`, the selected values inside `within` added (all of +/// them when `None`) exactly as the strategies hold them, ascending and distinct. +/// +/// The steps run from `from` — or, `on_multiples`, over the multiples of the step between the +/// edges, the edges themselves tried beside them where they are not ones (an automatic edge +/// kept off zero, [`FieldSpan::auto`]). `to` is tried where the steps do not land on it, so both +/// edges always are. The steps are rounded before they are compared; a selected value, which is +/// how a strategy spells it, equals the step that spells the same. +fn cut( + shown: Shown, + places: u32, + selected: &[f64], + within: Option<(f64, f64)>, + on_multiples: bool, +) -> Vec { + let mut points: Vec = Vec::new(); + if shown.step > 0.0 { + if on_multiples { + let (lo_q, hi_q) = (shown.from / shown.step, shown.to / shown.step); + let first = (lo_q - near(lo_q)).ceil() as i64; + let last = (hi_q + near(hi_q)).floor() as i64; + points.extend((first..=last).map(|k| snap(k as f64 * shown.step, places))); + } else { + let span = (shown.to - shown.from) / shown.step; + let count = (span + near(span)).floor() as usize + 1; + points.extend((0..count).map(|i| snap(shown.from + i as f64 * shown.step, places))); + } + } + points.push(snap(shown.from, places)); + points.push(snap(shown.to, places)); + let inside = |v: f64| within.is_none_or(|(lo, hi)| v >= lo - 1e-12 && v <= hi + 1e-12); + points.extend(selected.iter().copied().filter(|v| inside(*v))); + points.sort_by(f64::total_cmp); + points.dedup(); + points +} + +/// Each number knob's candidate values for one search. A number field without an entry has no +/// value to try and is not varied. +#[derive(Clone, Debug, Default)] +pub struct Grids(HashMap<&'static str, Arc<[f64]>>); + +impl Grids { + /// Grids out of `(key, points)` pairs; the points are taken as they come. + pub fn of(entries: impl IntoIterator)>) -> Self { + Self(entries.into_iter().collect()) + } + + /// Set one field's points. + pub fn insert(&mut self, key: &'static str, points: Arc<[f64]>) { + self.0.insert(key, points); + } + + /// A number field's points; empty for any other field, or one without a grid. + pub fn values(&self, field: &TickParam) -> &[f64] { + match field.kind { + ParamKind::Num => self.0.get(field.key).map_or(&[], |points| points), + ParamKind::Bool | ParamKind::Enum(_) => &[], + } + } + + /// How many values a field's grid offers. + pub fn arity(&self, field: &TickParam) -> usize { + match field.kind { + ParamKind::Num => self.values(field).len(), + ParamKind::Bool => 2, + ParamKind::Enum(options) => options.len(), + } + } + + /// The spelling of one grid value in the strategy's format. `index` is below + /// [`Self::arity`]. + pub fn spell(&self, field: &TickParam, index: usize) -> String { + match field.kind { + ParamKind::Num => spell_number(self.values(field)[index]), + ParamKind::Bool => (if index == 0 { "NO" } else { "YES" }).to_string(), + ParamKind::Enum(options) => options[index].to_string(), + } + } +} + +/// A number in the strategy's spelling: no fraction for a whole one, else the shortest form. +pub fn spell_number(v: f64) -> String { + if v.fract() == 0.0 { + format!("{v:.0}") + } else { + format!("{v}") + } +} + +/// The decimal digits `v` carries in its shortest form, at most [`MAX_DECIMALS`]. +fn decimals_of(v: f64) -> u32 { + let text = format!("{}", v.abs()); + text.split_once('.') + .map_or(0, |(_, fraction)| fraction.len() as u32) + .min(MAX_DECIMALS) +} + +/// `10^-decimals`. +fn quantum(decimals: u32) -> f64 { + 10f64.powi(-(decimals.min(MAX_DECIMALS) as i32)) +} + +/// `v` rounded to `places` decimals — the nearest double to that decimal, which prints short. +fn snap(v: f64, places: u32) -> f64 { + let scale = 10f64.powi(places as i32); + let out = (v * scale).round() / scale; + // No negative zero in a grid: it spells "-0". + if out == 0.0 { 0.0 } else { out } +} + +/// Whether two values are the same point of a grid. +fn same(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/params/range/tests.rs b/crates/moon-core/src/db/tuner/ticks/params/range/tests.rs new file mode 100644 index 00000000..65cc1754 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/params/range/tests.rs @@ -0,0 +1,439 @@ +//! The automatic search ranges and the typed ones over them. + +use std::collections::HashMap; + +use super::*; +use crate::db::tuner::LiveStrategy; +use crate::feed::strategy_deps::FieldDeps; + +fn live(kind: &str, values: &[(&str, &str)]) -> LiveStrategy { + LiveStrategy { + kind: kind.to_string(), + values: values + .iter() + .map(|(k, v)| (k.to_ascii_lowercase(), (*v).to_string())) + .collect(), + } +} + +fn span(lo: f64, hi: f64, decimals: u32, selected: &[f64]) -> FieldSpan { + FieldSpan { + lo, + hi, + decimals, + selected: selected.to_vec(), + } +} + +/// The plan's own example (2026-09-25): MoonShot's corridor p5 0.7, p95 5.0 on this machine, its +/// values to the hundredth, a selected strategy at 1.7, twenty steps — a round step of 0.25 from +/// 0.5 to 5, and the strategy's own 1.7 in the grid exactly, not snapped to 1.75. +#[test] +fn the_corridor_is_cut_into_round_steps_and_keeps_the_strategys_own_value() { + // Ten at each end, eighty between them, with the hundredths live corridors carry. + let population: Vec = [0.7; 10] + .into_iter() + .chain((0..80).map(|i| [1.25, 2.0, 3.35, 4.5][i % 4])) + .chain([5.0; 10]) + .collect(); + let s = field_span(&population, Some(0.0), &[1.7]).expect("a span"); + // The default 0 widens the span down. + assert_eq!(s.lo, 0.0); + let s = field_span(&population, None, &[1.7]).expect("a span"); + assert_eq!((s.lo, s.hi, s.decimals), (0.7, 5.0, 2), "{s:?}"); + let resolved = resolve(Some(&s), &TickRange::default(), false, 20); + let shown = resolved.shown.expect("shown"); + assert_eq!(shown.step, 0.25); + assert_eq!(shown.from, 0.5); + assert_eq!(shown.to, 5.0); + assert!(resolved.points.contains(&1.7)); + assert!(resolved.points.len() <= 20 + 1, "{}", resolved.points.len()); + assert!( + resolved.points.iter().all(|v| format!("{v}").len() <= 4), + "{:?}", + resolved.points + ); +} + +/// A field 5 % of the strategies set, the rest at its default of 0: the tails are cut over the +/// values set, so the range still reaches them, and the default stays in. +#[test] +fn a_field_few_strategies_set_is_ranged_over_those_that_set_it() { + let mut strategies: Vec = (0..57) + .map(|i| { + let value = if i % 2 == 0 { "0.001" } else { "0.0015" }; + live("MoonShot", &[("MShotAdd24hDelta", value)]) + }) + .collect(); + strategies.extend((0..1000).map(|_| live("MoonShot", &[]))); + let defaults = HashMap::from([("mshotadd24hdelta".to_string(), 0.0)]); + let population = Population::of(&strategies, &defaults, &FieldDeps::bundled()); + let values = population.values(&["MoonShot".to_string()], "MShotAdd24hDelta"); + assert_eq!(values.len(), 57, "only the strategies that set it"); + let s = field_span(&values, Some(0.0), &[]).expect("a span"); + assert_eq!(s.lo, 0.0); + assert_eq!(s.hi, 0.0015); + assert_eq!(s.decimals, 4); +} + +/// A stop per cent left in the dump behind `UseStopLoss = NO` says nothing about stops: +50 on +/// this machine (2026-09-25) would stretch the range past every stop in use. +#[test] +fn a_value_behind_a_switch_that_is_off_is_not_counted() { + let strategies = vec![ + live("MoonShot", &[("UseStopLoss", "YES"), ("StopLoss", "-3")]), + live("MoonShot", &[("UseStopLoss", "NO"), ("StopLoss", "50")]), + // No switch in the dump: the model's fallback, on. + live("MoonShot", &[("StopLoss", "-1")]), + ]; + let population = Population::of(&strategies, &HashMap::new(), &FieldDeps::bundled()); + let mut values = population.values(&[], "StopLoss"); + values.sort_by(f64::total_cmp); + assert_eq!(values, vec![-3.0, -1.0]); +} + +/// A kind with a handful of values is ranged over every kind: its own percentiles would be its +/// few values. +#[test] +fn a_kind_with_few_values_takes_every_kind() { + let mut strategies: Vec = (0..30) + .map(|i| live("MoonShot", &[("SellPrice", &format!("{}", 1 + i % 3))])) + .collect(); + strategies.push(live("Drops", &[("SellPrice", "9")])); + let population = Population::of(&strategies, &HashMap::new(), &FieldDeps::bundled()); + assert_eq!( + population + .values(&["MoonShot".to_string()], "SellPrice") + .len(), + 30 + ); + assert_eq!( + population.values(&["Drops".to_string()], "SellPrice").len(), + 31 + ); +} + +/// Negative fields are cut the same way: a stop from −5 to −0.1. Its values carry tenths, so the +/// round step past the raw 0.26 is 0.5 — 0.25 would put hundredths into a field that holds none. +#[test] +fn a_negative_range_is_cut_into_steps_below_zero() { + let s = span(-5.0, -0.1, 1, &[-1.5]); + let resolved = resolve(Some(&s), &TickRange::default(), false, 20); + let shown = resolved.shown.expect("shown"); + assert_eq!(shown.step, 0.5); + assert!(shown.from <= -5.0, "{shown:?}"); + // The top edge stays where the span ends: snapped out to a step it would be 0, "no stop". + assert_eq!(shown.to, -0.1); + assert!( + resolved.points.iter().all(|v| *v < 0.0), + "{:?}", + resolved.points + ); + assert!(resolved.points.contains(&-1.5) && resolved.points.contains(&-0.1)); +} + +/// The same on the other side: a corridor all above zero never starts at a corridor of nothing. +#[test] +fn a_positive_range_never_starts_at_zero() { + let s = span(0.05, 8.0, 2, &[]); + let resolved = resolve(Some(&s), &TickRange::default(), false, 20); + let shown = resolved.shown.expect("shown"); + assert_eq!(shown.from, 0.05); + assert!( + resolved.points.iter().all(|v| *v > 0.0), + "{:?}", + resolved.points + ); + // The rest stays on the round steps; the kept edge is tried beside them. + let step = shown.step; + let off: Vec = resolved + .points + .iter() + .copied() + .filter(|v| ((v / step) - (v / step).round()).abs() > 1e-9) + .collect(); + assert_eq!(off, vec![0.05], "{:?}", resolved.points); +} + +/// A typed "to" alone keeps the automatic "from" and its round steps. +#[test] +fn a_typed_top_alone_keeps_the_round_steps() { + let s = span(0.05, 8.0, 2, &[]); + let typed = TickRange { + to: Some(4.0), + ..TickRange::default() + }; + let resolved = resolve(Some(&s), &typed, false, 20); + let step = resolved.shown.expect("shown").step; + assert!( + resolved + .points + .iter() + .filter(|v| **v != 0.05) + .all(|v| ((v / step) - (v / step).round()).abs() < 1e-9), + "{:?}", + resolved.points + ); +} + +/// A strategy's own value keeps every digit it has, past the grid's precision too. +#[test] +fn a_selected_value_joins_the_grid_exactly() { + let s = span(0.0, 1.0, 2, &[0.1234567]); + let resolved = resolve(Some(&s), &TickRange::default(), false, 20); + assert!( + resolved.points.contains(&0.1234567), + "{:?}", + resolved.points + ); +} + +/// Large edges with a fine typed step: the division's noise does not add a point past the cap. +#[test] +fn a_fine_step_over_large_values_is_counted_right() { + let s = span(1000.0, 1001.0, 5, &[]); + let typed = TickRange { + from: Some(1000.0), + to: Some(1000.00199), + step: Some(0.00001), + }; + let resolved = resolve(Some(&s), &typed, false, 20); + assert_eq!(resolved.error, None); + assert_eq!(resolved.points.len(), 200); +} + +/// A span that does cross zero keeps it: there 0 is one of the values in use. +#[test] +fn a_range_across_zero_keeps_zero() { + let s = span(-0.5, 2.0, 1, &[]); + let resolved = resolve(Some(&s), &TickRange::default(), false, 20); + assert!(resolved.points.contains(&0.0), "{:?}", resolved.points); +} + +/// Both typed edges are tried, the top one too when the step does not land on it; the count cap +/// counts it. +#[test] +fn a_typed_top_edge_off_the_step_is_tried_too() { + let s = span(0.0, 10.0, 1, &[]); + let typed = TickRange { + from: Some(1.0), + to: Some(3.0), + step: Some(0.7), + }; + let resolved = resolve(Some(&s), &typed, false, 20); + assert_eq!(&*resolved.points, &[1.0, 1.7, 2.4, 3.0]); + let at_cap = TickRange { + from: Some(0.0), + to: Some(199.5), + step: Some(1.0), + }; + // 200 steps and the edge off them: 201 points, one past the cap. + assert_eq!( + resolve(Some(&s), &at_cap, false, 20).error, + Some(RangeError::TooMany) + ); +} + +/// Large values with fine digits keep their neighbours apart: points are compared as they spell. +#[test] +fn close_points_of_large_values_are_not_merged() { + let s = span(1000.0, 1000.00002, 6, &[]); + let resolved = resolve(Some(&s), &TickRange::default(), false, 3); + assert_eq!(resolved.points.len(), 3, "{:?}", resolved.points); +} + +/// A step typed alone on a field nothing is known of has nothing to cut: said, not swallowed. +#[test] +fn a_step_alone_with_nothing_known_is_refused() { + let typed = TickRange { + step: Some(1.0), + ..TickRange::default() + }; + let resolved = resolve(None, &typed, false, 20); + assert_eq!(resolved.error, Some(RangeError::NoEdges)); + assert!(resolved.points.is_empty()); +} + +/// A step never finer than the field's precision: whole-number values give whole steps. +#[test] +fn a_whole_number_field_steps_by_whole_numbers() { + let s = span(0.0, 5.0, 0, &[]); + let resolved = resolve(Some(&s), &TickRange::default(), false, 20); + assert_eq!(resolved.shown.expect("shown").step, 1.0); + assert_eq!(&*resolved.points, &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0]); +} + +/// A step of 0.1 adds up without the float's tail: `0.30000000000000004` would be written to a +/// strategy as it spells. +#[test] +fn a_tenth_step_spells_clean_values() { + let s = span(0.0, 1.0, 1, &[]); + let resolved = resolve(Some(&s), &TickRange::default(), false, 11); + assert_eq!(resolved.shown.expect("shown").step, 0.1); + let spelled: Vec = resolved.points.iter().map(|v| spell_number(*v)).collect(); + assert_eq!( + spelled, + vec![ + "0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6", "0.7", "0.8", "0.9", "1" + ] + ); +} + +/// The developer's question of 2026-09-25: a step finer than the field holds must give each value +/// once — a step of 0.3 on an integer field from 1 to 3 is 1, 2, 3, not 1, 1, 2, 2, 2, 3. +#[test] +fn a_step_finer_than_an_integer_field_gives_each_value_once() { + let s = span(0.0, 10.0, 0, &[]); + let typed = TickRange { + from: Some(1.0), + to: Some(3.0), + step: Some(0.3), + }; + let resolved = resolve(Some(&s), &typed, true, 20); + assert_eq!(resolved.error, None); + assert_eq!(&*resolved.points, &[1.0, 2.0, 3.0]); +} + +/// On a field that is not an integer, the typed step's own precision is kept. +#[test] +fn a_typed_step_finer_than_the_values_holds_on_a_decimal_field() { + let s = span(10.0, 60.0, 0, &[]); + let typed = TickRange { + from: Some(10.0), + to: Some(11.0), + step: Some(0.5), + }; + let resolved = resolve(Some(&s), &typed, false, 20); + assert_eq!(&*resolved.points, &[10.0, 10.5, 11.0]); +} + +/// No grid ever holds a value twice, whatever the step and the selected values. +#[test] +fn no_grid_holds_a_value_twice() { + let s = span(0.0, 1.0, 2, &[0.25, 0.5, 0.5]); + for steps in [3, 7, 20, 200] { + let resolved = resolve(Some(&s), &TickRange::default(), false, steps); + let points = &resolved.points; + assert!( + points.windows(2).all(|w| w[0] < w[1]), + "{steps}: {points:?}" + ); + } +} + +/// A typed "to" alone keeps the automatic "from" and cuts a new step over the new span. +#[test] +fn a_typed_edge_alone_recuts_the_step() { + let s = span(0.0, 10.0, 1, &[]); + let typed = TickRange { + to: Some(2.0), + ..TickRange::default() + }; + let resolved = resolve(Some(&s), &typed, false, 20); + let shown = resolved.shown.expect("shown"); + assert_eq!((shown.from, shown.to), (0.0, 2.0)); + assert_eq!( + shown.step, 0.2, + "one step of the automatic 0.5 would leave five points" + ); +} + +/// A typed range the search cannot use is set aside for the automatic one, and says why. +#[test] +fn an_unusable_typed_range_falls_back_to_automatic() { + let s = span(0.0, 10.0, 1, &[]); + let auto = resolve(Some(&s), &TickRange::default(), false, 20); + let inverted = TickRange { + from: Some(5.0), + to: Some(1.0), + step: None, + }; + let zero = TickRange { + step: Some(0.0), + ..TickRange::default() + }; + let many = TickRange { + from: Some(0.0), + to: Some(1000.0), + step: Some(1.0), + }; + for (typed, error) in [ + (inverted, RangeError::Inverted), + (zero, RangeError::BadStep), + (many, RangeError::TooMany), + ] { + let resolved = resolve(Some(&s), &typed, false, 20); + assert_eq!(resolved.error, Some(error)); + assert_eq!(resolved.points, auto.points); + } +} + +/// Nothing known of a field — no strategy, no default, no selection — gives no span, and no grid +/// the search could vary. +#[test] +fn a_field_nothing_is_known_of_has_no_grid() { + assert_eq!(field_span(&[], None, &[]), None); + let resolved = resolve(None, &TickRange::default(), false, 20); + assert!(resolved.points.is_empty() && resolved.shown.is_none()); +} + +/// Everyone at one value is a one-point grid, not a range of nothing. +#[test] +fn one_value_everywhere_is_a_one_point_grid() { + let s = field_span(&[3.0, 3.0], Some(3.0), &[3.0]).expect("a span"); + let resolved = resolve(Some(&s), &TickRange::default(), false, 20); + assert_eq!(&*resolved.points, &[3.0]); + assert_eq!(resolved.shown.expect("shown").step, 0.0); +} + +/// Every number knob of the axis gets a grid off an ordinary set of live strategies — the guard +/// against a knob added with a key no strategy spells: its row would search nothing. +#[test] +fn every_number_knob_is_ranged_off_strategies_that_set_it() { + let strategies: Vec = (0..25) + .map(|i| { + let values: Vec<(String, String)> = number_fields() + .map(|f| (f.key.to_string(), format!("{}", 1 + i % 5))) + .chain( + [ + "UseStopLoss", + "UseSecondStop", + "UseStopLoss3", + "UseTrailing", + "UseTakeProfit", + "MShotSellAtLastPrice", + ] + .map(|k| (k.to_string(), "YES".to_string())), + ) + .collect(); + let borrowed: Vec<(&str, &str)> = values + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + live("MoonShot", &borrowed) + }) + .collect(); + let population = Population::of(&strategies, &HashMap::new(), &FieldDeps::bundled()); + for field in number_fields() { + let values = population.values(&["MoonShot".to_string()], field.key); + let s = field_span(&values, None, &[]); + let points = resolve(s.as_ref(), &TickRange::default(), false, DEFAULT_STEPS).points; + assert!(points.len() >= 2, "{}: {points:?}", field.key); + } +} + +/// The grid spells what the strategy format expects: no fraction on a whole number, no trailing +/// noise, no negative zero. +#[test] +fn the_grid_spells_numbers_as_a_strategy_does() { + let grids = Grids::of([("SellPrice", Arc::from(vec![0.0, 1.0, 1.5, -0.25]))]); + let field = TICK_PARAMS + .iter() + .find(|f| f.key == "SellPrice") + .expect("a knob"); + assert_eq!(grids.arity(field), 4); + let spelled: Vec = (0..4).map(|i| grids.spell(field, i)).collect(); + assert_eq!(spelled, vec!["0", "1", "1.5", "-0.25"]); + assert_eq!(snap(-0.0000001, 2), 0.0); + assert_eq!(spell_number(snap(-0.0000001, 2)), "0"); +} diff --git a/crates/moon-core/src/db/tuner/ticks/params/tests.rs b/crates/moon-core/src/db/tuner/ticks/params/tests.rs index b6cd1bf0..995bcacd 100644 --- a/crates/moon-core/src/db/tuner/ticks/params/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/params/tests.rs @@ -1,24 +1,7 @@ -//! The knobs' grids. +//! The knobs and what writing one moves. use super::*; -/// The corridor's grids step by 0.05 to 8, and hold a strategy's own spellings exactly: 1.7 is a -/// step, spelled back as `1.7`, not snapped to 1.75. -#[test] -fn the_corridor_grids_step_by_a_twentieth_to_eight() { - for key in ["MShotPrice", "MShotPriceMin"] { - let field = TICK_PARAMS.iter().find(|f| f.key == key).expect("a knob"); - let ParamKind::Num { grid } = &field.kind else { - panic!("{key} is a number"); - }; - assert_eq!(grid.len(), 160, "{key}"); - assert_eq!(grid.first().copied(), Some(0.05)); - assert_eq!(grid.last().copied(), Some(8.0)); - assert!(grid.contains(&1.7) && grid.contains(&1.2), "{key}"); - assert_eq!(format!("{}", grid[33]), "1.7"); - } -} - /// A write's corridor warning must key on every field that moves a MoonShot's corridor: the /// Entry group, and `MaxModifier` of the Exit group, which caps the `MShotAdd*` sum too — and on /// no field the corridor does not read. diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index 73dcbdff..30cb29c8 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -1,5 +1,5 @@ //! The search of the "Entry/Exit" axis: coordinate descent with restarts over the discrete -//! grids of [`TICK_PARAMS`], scoring a point by REPLAYING every covered deal under it — the shape +//! grids the caller hands it ([`SearchParams::grids`], `params::range`), scoring a point by REPLAYING every covered deal under it — the shape //! of `threshold_search`, with the SQL mask replaced by [`simulate`]. Restart 0 starts from the //! strategy itself; the others from the strategy moved a few steps on a few fields, each walking //! the fields in an order of its own. A pass that moves no single field then tries PAIRS of the @@ -38,6 +38,7 @@ use std::sync::Arc; use rayon::prelude::*; use super::mshot::{CorridorStep, EntryMethod, MshotEntry, MshotParams}; +use super::params::range::Grids; use super::params::{ ParamGroup, ParamKind, StrategyValues, TICK_PARAMS, TickParam, exit_params, mshot_params, }; @@ -123,6 +124,9 @@ pub struct SearchParams<'a> { pub vary_exit: bool, /// Field keys held at each deal's base value. pub locked: &'a HashSet, + /// Each number field's candidate values (`params::range::resolve`); a number field without + /// one has nothing to try and is not varied. + pub grids: &'a Grids, /// Restart count, at least 1. pub restarts: usize, /// Minimum trades a point must keep, or one tenth of the fitted sample. @@ -225,6 +229,7 @@ struct Walked { #[allow(clippy::too_many_arguments)] fn descend( mut point: Point, + grids: &Grids, order: &[&'static TickParam], pairs: &[&'static TickParam], coupling: &coupled::Coupling<'_>, @@ -248,8 +253,8 @@ fn descend( continue; } let mut current = point.get(field.key).cloned(); - for index in 0..arity(&field.kind) { - let candidate = spell(&field.kind, index); + for index in 0..grids.arity(field) { + let candidate = grids.spell(field, index); if current.as_deref() == Some(candidate.as_str()) { continue; } @@ -280,18 +285,18 @@ fn descend( continue; } let (Some(d), Some(u)) = ( - grid_index(down, &point, start), - grid_index(up, &point, start), + grid_index(grids, down, &point, start), + grid_index(grids, up, &point, start), ) else { continue; }; - if d == 0 || u + 1 >= arity(&up.kind) { + if d == 0 || u + 1 >= grids.arity(up) { continue; } let (was_down, was_up) = (point.get(down.key).cloned(), point.get(up.key).cloned()); - point.insert(down.key, spell(&down.kind, d - 1)); - point.insert(up.key, spell(&up.kind, u + 1)); + point.insert(down.key, grids.spell(down, d - 1)); + point.insert(up.key, grids.spell(up, u + 1)); let trial = evaluate(&point); if better_score(&trial, &score, min_n) { score = trial; @@ -307,7 +312,7 @@ fn descend( if !coupling.is_stuck(coefficient, term, &point) { continue; } - for path in coupled::Coupling::diagonals(coefficient, term) { + for path in coupled::Coupling::diagonals(grids, coefficient, term) { improved |= coupled::walk_path( &mut point, (coefficient, term), @@ -348,15 +353,16 @@ fn restore(point: &mut Point, key: &'static str, was: Option) { /// Where a number field stands on its grid: the step the point holds, else the base's /// (`start`). `None` for a field that is not a number, or a base with no value to snap. fn grid_index( + grids: &Grids, field: &TickParam, point: &Point, start: &HashMap<&'static str, usize>, ) -> Option { - let ParamKind::Num { .. } = &field.kind else { + if field.kind != ParamKind::Num { return None; - }; + } match point.get(field.key) { - Some(value) => (0..arity(&field.kind)).find(|&i| spell(&field.kind, i) == *value), + Some(value) => (0..grids.arity(field)).find(|&i| grids.spell(field, i) == *value), None => start.get(field.key).copied(), } } @@ -381,6 +387,7 @@ fn shuffle(items: &mut [T], state: &mut u64) { /// steps either way from where it stands (`start`), any other field to a value of its own. fn perturb( point: &mut Point, + grids: &Grids, order: &[&'static TickParam], start: &HashMap<&'static str, usize>, state: &mut u64, @@ -391,9 +398,14 @@ fn perturb( let moves = 1 + (next_random(state) % 3) as usize; for _ in 0..moves { let field = order[(next_random(state) % order.len() as u64) as usize]; - let n = arity(&field.kind); + let n = grids.arity(field); + // A field with nothing to try is not varied (`varied`); guarded all the same, as the + // modulo and the `n - 1` below would not survive it. + if n == 0 { + continue; + } let index = match (&field.kind, start.get(field.key)) { - (ParamKind::Num { .. }, Some(&at)) => { + (ParamKind::Num, Some(&at)) => { let step = 1 + (next_random(state) % 3) as usize; if next_random(state) % 2 == 0 { at.saturating_sub(step) @@ -403,7 +415,7 @@ fn perturb( } _ => (next_random(state) % n as u64) as usize, }; - point.insert(field.key, spell(&field.kind, index)); + point.insert(field.key, grids.spell(field, index)); } } @@ -448,36 +460,13 @@ fn params_of( (entry, exit_params(&sv, model)) } -/// The spelling of one grid value in the strategy's format. -fn spell(kind: &ParamKind, index: usize) -> String { - match kind { - ParamKind::Num { grid } => { - let v = grid[index]; - if v.fract() == 0.0 { - format!("{v:.0}") - } else { - format!("{v}") - } - } - ParamKind::Bool => (if index == 0 { "NO" } else { "YES" }).to_string(), - ParamKind::Enum(options) => options[index].to_string(), - } -} - -/// How many values a field's grid offers. -fn arity(kind: &ParamKind) -> usize { - match kind { - ParamKind::Num { grid } => grid.len(), - ParamKind::Bool => 2, - ParamKind::Enum(options) => options.len(), - } -} - -/// The fields one search varies: those it offers ([`deps::offered`]) less the locked. +/// The fields one search varies: those it offers ([`deps::offered`]) less the locked and the +/// number fields with nothing to try — no grid, as for a field nothing is known of. fn varied<'a>(p: &SearchParams<'a>) -> Vec<&'static super::params::TickParam> { deps::offered(p) .into_iter() .filter(|f| !p.locked.contains(f.key)) + .filter(|f| p.grids.arity(f) > 0) .collect() } @@ -843,7 +832,7 @@ pub fn suggest( let pairs: Vec<&'static TickParam> = if params.vary_entry { fields .iter() - .filter(|f| f.group == ParamGroup::Entry && matches!(f.kind, ParamKind::Num { .. })) + .filter(|f| f.group == ParamGroup::Entry && f.kind == ParamKind::Num) .copied() .collect() } else { @@ -867,10 +856,19 @@ pub fn suggest( if restart > 0 { let mut state = restart_seed(seed, restart); shuffle(&mut order, &mut state); - perturb(&mut point, &order, &start, &mut state); + perturb(&mut point, params.grids, &order, &start, &mut state); } let walked = descend( - point, &order, &pairs, &coupling, &start, &evaluate, min_n, max_passes, handle, + point, + params.grids, + &order, + &pairs, + &coupling, + &start, + &evaluate, + min_n, + max_passes, + handle, )?; handle.record_restart(); Some(Run { @@ -1167,6 +1165,9 @@ mod closing; pub use self::closing::unguarded_strategies; mod coupled; mod deps; +pub(in crate::db::tuner::ticks) use self::deps::strategy_values; +#[cfg(test)] +pub(in crate::db::tuner::ticks) mod test_grids; #[cfg(test)] mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs index 7eb5ee53..5e11ca3e 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs @@ -72,6 +72,7 @@ fn a_point_that_leaves_a_deal_open_is_refused() { vary_entry: false, vary_exit: true, locked: &locked, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 2, min_n: Some(1), seed: Some(3), @@ -118,6 +119,7 @@ fn a_deal_the_strategy_itself_leaves_open_leaves_the_sample() { vary_entry: false, vary_exit: true, locked: &locked, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 2, min_n: Some(3), seed: Some(5), diff --git a/crates/moon-core/src/db/tuner/ticks/search/coupled.rs b/crates/moon-core/src/db/tuner/ticks/search/coupled.rs index dcfacb0a..a079e5c7 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/coupled.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/coupled.rs @@ -19,10 +19,11 @@ //! one value for all of them, and one strategy that applies the sum is enough for the field to //! move a column. -use super::{Point, better_score, restore, spell}; +use super::{Point, better_score, restore}; use crate::db::metrics::Tally; use crate::db::tuner::threshold_search::SearchHandle; -use crate::db::tuner::ticks::params::{ParamKind, ParamSection, TickParam}; +use crate::db::tuner::ticks::params::range::Grids; +use crate::db::tuner::ticks::params::{ParamSection, TickParam}; use crate::db::tuner::ticks::{EntryParams, ExitParams}; /// The model parameters of every strategy of the sample at a point, completed as a scored @@ -144,16 +145,13 @@ impl<'a> Coupling<'a> { /// grid goes below zero — against the term's grid above zero, both spanned end to end over /// the longer of the two, so a path runs from the smallest product to the largest. pub(super) fn diagonals( + grids: &Grids, coefficient: &TickParam, term: &TickParam, ) -> Vec> { - let (Some(up), Some(down), Some(terms)) = ( - steps(coefficient, |v| v > 0.0), - steps(coefficient, |v| v < 0.0), - steps(term, |v| v > 0.0), - ) else { - return Vec::new(); - }; + let up = steps(grids, coefficient, |v| v > 0.0); + let down = steps(grids, coefficient, |v| v < 0.0); + let terms = steps(grids, term, |v| v > 0.0); [up, down] .into_iter() .filter(|side| !side.is_empty() && !terms.is_empty()) @@ -169,8 +167,8 @@ impl<'a> Coupling<'a> { } }; ( - spell(&coefficient.kind, side[at(side.len())]), - spell(&term.kind, terms[at(terms.len())]), + grids.spell(coefficient, side[at(side.len())]), + grids.spell(term, terms[at(terms.len())]), ) }) .collect::>() @@ -224,15 +222,13 @@ fn silent(exit: &ExitParams) -> bool { } } -/// The grid indices of a number field whose value passes `keep`, nearest zero first; `None` -/// for a field that is not a number. -fn steps(field: &TickParam, keep: impl Fn(f64) -> bool) -> Option> { - let ParamKind::Num { grid } = &field.kind else { - return None; - }; +/// The grid indices of a number field whose value passes `keep`, nearest zero first; none for +/// a field that is not a number or has no grid. +fn steps(grids: &Grids, field: &TickParam, keep: impl Fn(f64) -> bool) -> Vec { + let grid = grids.values(field); let mut out: Vec = (0..grid.len()).filter(|&i| keep(grid[i])).collect(); out.sort_by(|&a, &b| grid[a].abs().total_cmp(&grid[b].abs())); - Some(out) + out } /// Walk one path of two fields as one move: each step sets both, and a step that beats the diff --git a/crates/moon-core/src/db/tuner/ticks/search/coupled/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/coupled/tests.rs index 3ac4a2b3..dfd396c8 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/coupled/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/coupled/tests.rs @@ -93,7 +93,11 @@ fn a_field_is_inert_only_while_its_partner_is_zero_on_every_strategy() { /// the coefficient's grid up and down, the term's up. #[test] fn a_diagonal_runs_both_grids_from_the_smallest_step_to_the_largest() { - let paths = Coupling::diagonals(field("SellModifier"), field("Add1minDelta")); + let paths = Coupling::diagonals( + crate::db::tuner::ticks::search::test_grids::legacy(), + field("SellModifier"), + field("Add1minDelta"), + ); assert_eq!(paths.len(), 2, "up and down"); let pair = |p: &(String, String)| (p.0.clone(), p.1.clone()); let s = |a: &str, b: &str| (a.to_string(), b.to_string()); @@ -134,6 +138,7 @@ fn the_descent_leaves_the_zero_corner_along_the_diagonal() { let coupling = Coupling::of(&order, &per_base); let walked = descend( Point::new(), + crate::db::tuner::ticks::search::test_grids::legacy(), &order, &[], &coupling, @@ -151,6 +156,7 @@ fn the_descent_leaves_the_zero_corner_along_the_diagonal() { let alone = descend( Point::new(), + crate::db::tuner::ticks::search::test_grids::legacy(), &order, &[], &Coupling::none(), @@ -182,6 +188,7 @@ fn an_inert_term_costs_no_replay() { let coupling = Coupling::of(&order, &per_base); descend( Point::new(), + crate::db::tuner::ticks::search::test_grids::legacy(), &order, &[], &coupling, diff --git a/crates/moon-core/src/db/tuner/ticks/search/deps.rs b/crates/moon-core/src/db/tuner/ticks/search/deps.rs index cd4ae3a4..16ad4c06 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/deps.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/deps.rs @@ -26,8 +26,9 @@ use std::collections::HashMap; -use super::{Point, SearchParams, spell}; +use super::{Point, SearchParams}; use crate::db::tuner::ticks::TICK_PARAMS; +use crate::db::tuner::ticks::params::range::Grids; use crate::db::tuner::ticks::params::{ParamGroup, ParamKind, TickParam}; use crate::feed::strategy_deps::{FieldDeps, Values}; @@ -92,9 +93,10 @@ pub(super) fn dependents_of( let start: HashMap<&'static str, usize> = offered .iter() .filter_map(|f| { - let ParamKind::Num { grid } = &f.kind else { + let grid = params.grids.values(f); + if grid.is_empty() { return None; - }; + } // The schema's defaults are keyed lowercase (`strategy_field_defaults`). let default = params.defaults.get(&f.key.to_ascii_lowercase()).copied(); let mut values: Vec = match params.held.get(f.key).and_then(parse) { @@ -109,7 +111,7 @@ pub(super) fn dependents_of( Some((f.key, super::nearest_step(grid, value))) }) .collect(); - let dependents = Dependents::new(&offered, &start); + let dependents = Dependents::new(params.grids, &offered, &start); (start, dependents) } @@ -121,14 +123,19 @@ pub(super) struct Dependents { impl Dependents { /// Args: + /// grids: The search's grids. /// fields: The fields the search offers ([`offered`]), varied or locked. /// start: Where each number field starts on its grid; one without a start is never /// completed. - pub(super) fn new(fields: &[&'static TickParam], start: &HashMap<&'static str, usize>) -> Self { + pub(super) fn new( + grids: &Grids, + fields: &[&'static TickParam], + start: &HashMap<&'static str, usize>, + ) -> Self { let numbers = fields .iter() - .filter(|f| matches!(f.kind, ParamKind::Num { .. })) - .filter_map(|f| Some((*f, spell(&f.kind, *start.get(f.key)?)))) + .filter(|f| f.kind == ParamKind::Num) + .filter_map(|f| Some((*f, grids.spell(f, *start.get(f.key)?)))) .collect(); Self { rules: FieldDeps::bundled(), @@ -199,6 +206,16 @@ impl Dependents { } } +/// One strategy's values as it stands, as the rules read them ([`effective`] with nothing laid +/// over) — what the search's automatic ranges ask "is this field in effect here" of +/// (`params::range::Population`). +pub(in crate::db::tuner::ticks) fn strategy_values( + own: &HashMap, + defaults: &HashMap, +) -> Values { + effective(own, &HashMap::new(), &Point::new(), defaults) +} + /// One strategy's values as the rules read them: its own, `held` over them, the point over that, /// lowercase; a condition field none of them spells reads the live schema's default, else the /// model's fallback. diff --git a/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs index 78017f39..ed922eb9 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs @@ -31,7 +31,11 @@ fn deps() -> Dependents { let start: HashMap<&'static str, usize> = [("TrailingPercent", 0), ("TakeProfit", 2)] .into_iter() .collect(); - Dependents::new(&fields, &start) + Dependents::new( + crate::db::tuner::ticks::search::test_grids::legacy(), + &fields, + &start, + ) } /// A switch turned on with no value behind it gets one: the take profit's per cent at its start. @@ -105,7 +109,11 @@ fn a_completion_reaches_every_strategy_whatever_the_order() { let start: HashMap<&'static str, usize> = [("PriceDownTimer", 3), ("PriceDownPercent", 4)] .into_iter() .collect(); - let deps = Dependents::new(&fields, &start); + let deps = Dependents::new( + crate::db::tuner::ticks::search::test_grids::legacy(), + &fields, + &start, + ); let off = own(&[("PriceDownTimer", "0")]); let pct = own(&[("PriceDownPercent", "10")]); for owns in [[&off, &pct], [&pct, &off]] { @@ -125,7 +133,11 @@ fn a_completion_reaches_every_strategy_whatever_the_order() { fn a_field_at_its_default_is_not_completed_from_other_strategies() { let fields = [field("MShotAddBTCDelta")]; let start: HashMap<&'static str, usize> = [("MShotAddBTCDelta", 1)].into_iter().collect(); - let deps = Dependents::new(&fields, &start); + let deps = Dependents::new( + crate::db::tuner::ticks::search::test_grids::legacy(), + &fields, + &start, + ); let set = own(&[("MShotAddBTCDelta", "0.03")]); let bare = own(&[]); let out = deps.complete( @@ -178,6 +190,7 @@ fn a_search_of_one_field_completes_what_the_variant_switched_on() { vary_entry: false, vary_exit: true, locked: &locked, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 1, min_n: Some(4), seed: Some(3), @@ -218,7 +231,11 @@ fn a_field_is_completed_only_for_a_switch_the_variant_turns_on() { field("TakeProfit"), ]; let start: HashMap<&'static str, usize> = [("TakeProfit", 2)].into_iter().collect(); - let deps = Dependents::new(&fields, &start); + let deps = Dependents::new( + crate::db::tuner::ticks::search::test_grids::legacy(), + &fields, + &start, + ); let stored_on = own(&[("UseTrailing", "YES"), ("UseTakeProfit", "YES")]); let out = deps.complete(&point(&[]), &[&stored_on], &HashMap::new(), &HashMap::new()); assert!(!out.contains_key("TakeProfit"), "{out:?}"); @@ -237,10 +254,7 @@ fn a_field_is_completed_only_for_a_switch_the_variant_turns_on() { fn every_condition_of_a_number_knob_has_a_fallback() { let rules = FieldDeps::bundled(); let mut missing: Vec = Vec::new(); - for knob in TICK_PARAMS - .iter() - .filter(|f| matches!(f.kind, ParamKind::Num { .. })) - { + for knob in TICK_PARAMS.iter().filter(|f| f.kind == ParamKind::Num) { for condition in rules.conditions_of(knob.key) { let known = CONDITION_FALLBACKS.iter().any(|(k, _)| *k == condition); if !known && !missing.iter().any(|m| m == condition) { @@ -257,7 +271,11 @@ fn every_condition_of_a_number_knob_has_a_fallback() { fn a_sell_at_last_price_switched_on_brings_its_adjustment() { let fields = [field("MShotSellPriceAdjust")]; let start: HashMap<&'static str, usize> = [("MShotSellPriceAdjust", 0)].into_iter().collect(); - let deps = Dependents::new(&fields, &start); + let deps = Dependents::new( + crate::db::tuner::ticks::search::test_grids::legacy(), + &fields, + &start, + ); let bare = own(&[]); let out = deps.complete(&point(&[]), &[&bare], &HashMap::new(), &HashMap::new()); assert!(out.is_empty(), "{out:?}"); diff --git a/crates/moon-core/src/db/tuner/ticks/search/test_grids.rs b/crates/moon-core/src/db/tuner/ticks/search/test_grids.rs new file mode 100644 index 00000000..9fa1a1da --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/test_grids.rs @@ -0,0 +1,166 @@ +//! The ladders the axis searched by until 2026-09-25, kept as fixed grids for the tests: the +//! search's own behaviour is what they pin, and a test that moved with the live strategies' spread +//! would pin nothing. The axis itself takes its grids from `params::range`. + +use std::sync::Arc; + +use crate::db::tuner::ticks::params::range::Grids; + +const ADD: &[f64] = &[ + 0.0, 0.001, 0.002, 0.005, 0.01, 0.02, 0.03, 0.04, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, + 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0, +]; +const ADJUST: &[f64] = &[ + -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, + 1.6, 1.8, 2.0, +]; +const DELTA_ADD: &[f64] = &[ + 0.0, 0.001, 0.002, 0.005, 0.01, 0.02, 0.03, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0, + 1.5, 2.0, 3.0, +]; +const DISTANCE: &[f64] = &[ + 0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0, 75.0, 100.0, 150.0, 200.0, +]; +const DROP: &[f64] = &[ + -1.0, -0.5, -0.2, -0.1, 0.0, 0.01, 0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, +]; +const HOOK_LEVEL: &[f64] = &[ + 10.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 75.0, 80.0, 90.0, 100.0, +]; +const MAX_MODIFIER: &[f64] = &[ + 0.0, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 100.0, 130.0, 200.0, 1000.0, +]; +const PD_DELAY_S: &[f64] = &[0.0, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 30.0, 60.0]; +const PD_PCT: &[f64] = &[ + 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 70.0, + 80.0, 90.0, 100.0, +]; +const PD_TIMER_S: &[f64] = &[ + 0.0, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0, +]; +const PRICE: &[f64] = &[ + 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, + 0.9, 0.95, 1.0, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, + 1.75, 1.8, 1.85, 1.9, 1.95, 2.0, 2.05, 2.1, 2.15, 2.2, 2.25, 2.3, 2.35, 2.4, 2.45, 2.5, 2.55, + 2.6, 2.65, 2.7, 2.75, 2.8, 2.85, 2.9, 2.95, 3.0, 3.05, 3.1, 3.15, 3.2, 3.25, 3.3, 3.35, 3.4, + 3.45, 3.5, 3.55, 3.6, 3.65, 3.7, 3.75, 3.8, 3.85, 3.9, 3.95, 4.0, 4.05, 4.1, 4.15, 4.2, 4.25, + 4.3, 4.35, 4.4, 4.45, 4.5, 4.55, 4.6, 4.65, 4.7, 4.75, 4.8, 4.85, 4.9, 4.95, 5.0, 5.05, 5.1, + 5.15, 5.2, 5.25, 5.3, 5.35, 5.4, 5.45, 5.5, 5.55, 5.6, 5.65, 5.7, 5.75, 5.8, 5.85, 5.9, 5.95, + 6.0, 6.05, 6.1, 6.15, 6.2, 6.25, 6.3, 6.35, 6.4, 6.45, 6.5, 6.55, 6.6, 6.65, 6.7, 6.75, 6.8, + 6.85, 6.9, 6.95, 7.0, 7.05, 7.1, 7.15, 7.2, 7.25, 7.3, 7.35, 7.4, 7.45, 7.5, 7.55, 7.6, 7.65, + 7.7, 7.75, 7.8, 7.85, 7.9, 7.95, 8.0, +]; +const PRICE_MIN: &[f64] = &[ + 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, + 0.9, 0.95, 1.0, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, + 1.75, 1.8, 1.85, 1.9, 1.95, 2.0, 2.05, 2.1, 2.15, 2.2, 2.25, 2.3, 2.35, 2.4, 2.45, 2.5, 2.55, + 2.6, 2.65, 2.7, 2.75, 2.8, 2.85, 2.9, 2.95, 3.0, 3.05, 3.1, 3.15, 3.2, 3.25, 3.3, 3.35, 3.4, + 3.45, 3.5, 3.55, 3.6, 3.65, 3.7, 3.75, 3.8, 3.85, 3.9, 3.95, 4.0, 4.05, 4.1, 4.15, 4.2, 4.25, + 4.3, 4.35, 4.4, 4.45, 4.5, 4.55, 4.6, 4.65, 4.7, 4.75, 4.8, 4.85, 4.9, 4.95, 5.0, 5.05, 5.1, + 5.15, 5.2, 5.25, 5.3, 5.35, 5.4, 5.45, 5.5, 5.55, 5.6, 5.65, 5.7, 5.75, 5.8, 5.85, 5.9, 5.95, + 6.0, 6.05, 6.1, 6.15, 6.2, 6.25, 6.3, 6.35, 6.4, 6.45, 6.5, 6.55, 6.6, 6.65, 6.7, 6.75, 6.8, + 6.85, 6.9, 6.95, 7.0, 7.05, 7.1, 7.15, 7.2, 7.25, 7.3, 7.35, 7.4, 7.45, 7.5, 7.55, 7.6, 7.65, + 7.7, 7.75, 7.8, 7.85, 7.9, 7.95, 8.0, +]; +const SELL_DELAY_MS: &[f64] = &[0.0, 100.0, 250.0, 500.0, 1000.0]; +const SELL_MODIFIER: &[f64] = &[ + -0.5, -0.3, -0.2, -0.1, -0.05, 0.0, 0.03, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.75, 1.0, + 1.5, +]; +const SELL_PRICE: &[f64] = &[ + 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, + 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, +]; +const SL_COUNT: &[f64] = &[0.0, 1.0, 2.0, 3.0, 5.0, 10.0]; +const SL_DELAY_S: &[f64] = &[0.0, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0]; +const SL_TIME_S: &[f64] = &[0.0, 60.0, 300.0, 900.0, 1800.0, 3600.0, 7200.0]; +const STEP_LEVEL: &[f64] = &[ + -3.0, -2.0, -1.0, -0.5, -0.2, 0.0, 0.1, 0.2, 0.25, 0.3, 0.4, 0.5, 0.8, 1.0, 1.5, 2.0, +]; +const STOP: &[f64] = &[ + -15.0, -12.0, -10.0, -7.0, -5.0, -4.0, -3.0, -2.5, -2.0, -1.5, -1.0, -0.75, -0.5, -0.3, -0.2, + -0.1, +]; +const STOP_DELAY_S: &[f64] = &[0.0, 1.0, 2.0, 4.0, 6.0, 10.0, 20.0, 30.0]; +const STOP_MODIFIER: &[f64] = &[ + -0.5, -0.3, -0.2, -0.1, -0.05, 0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 1.0, +]; +const SWITCH_PCT: &[f64] = &[0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.3, 1.5, 2.0, 3.0, 5.0]; +const SWITCH_S: &[f64] = &[ + 0.0, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0, +]; +const TAKE_PROFIT: &[f64] = &[0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 5.0, 10.0]; +const TRAILING: &[f64] = &[ + -10.0, -5.0, -4.0, -3.0, -2.0, -1.8, -1.5, -1.0, -0.8, -0.5, -0.3, -0.2, -0.1, +]; +const TRAILING_EMA: &[f64] = &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; +const WAIT_S: &[f64] = &[0.0, 0.1, 0.3, 0.5, 1.0, 2.0, 5.0]; + +/// Every number knob at its former ladder. +pub(in crate::db::tuner::ticks) fn legacy() -> &'static Grids { + static GRIDS: std::sync::OnceLock = std::sync::OnceLock::new(); + GRIDS.get_or_init(|| { + Grids::of([ + ("MShotPrice", Arc::from(PRICE)), + ("MShotPriceMin", Arc::from(PRICE_MIN)), + ("MShotRaiseWait", Arc::from(WAIT_S)), + ("MShotReplaceDelay", Arc::from(WAIT_S)), + ("MShotAddHourlyDelta", Arc::from(ADD)), + ("MShotAdd3hDelta", Arc::from(ADD)), + ("MShotAdd15minDelta", Arc::from(ADD)), + ("MShotAdd5minDelta", Arc::from(ADD)), + ("MShotAdd1minDelta", Arc::from(ADD)), + ("MShotAdd24hDelta", Arc::from(ADD)), + ("MShotAddMarkDelta", Arc::from(ADD)), + ("MShotAddMarketDelta", Arc::from(ADD)), + ("MShotAddBTCDelta", Arc::from(ADD)), + ("MShotAddBTC5mDelta", Arc::from(ADD)), + ("MShotAddPriceBug", Arc::from(ADD)), + ("MShotAddDistance", Arc::from(DISTANCE)), + ("SellPrice", Arc::from(SELL_PRICE)), + ("MShotSellPriceAdjust", Arc::from(ADJUST)), + ("HookSellLevel", Arc::from(HOOK_LEVEL)), + ("SellDelay", Arc::from(SELL_DELAY_MS)), + ("MaxModifier", Arc::from(MAX_MODIFIER)), + ("PriceDownTimer", Arc::from(PD_TIMER_S)), + ("PriceDownPercent", Arc::from(PD_PCT)), + ("PriceDownDelay", Arc::from(PD_DELAY_S)), + ("PriceDownAllowedDrop", Arc::from(DROP)), + ("SellLevelDelay", Arc::from(SL_DELAY_S)), + ("SellLevelDelayNext", Arc::from(SL_DELAY_S)), + ("SellLevelTime", Arc::from(SL_TIME_S)), + ("SellLevelCount", Arc::from(SL_COUNT)), + ("SellLevelAdjust", Arc::from(DROP)), + ("SellLevelAllowedDrop", Arc::from(DROP)), + ("SellLevelWorkTime", Arc::from(SL_TIME_S)), + ("StopLossDelay", Arc::from(STOP_DELAY_S)), + ("StopLoss", Arc::from(STOP)), + ("TimeToSwitch2Stop", Arc::from(SWITCH_S)), + ("PriceToSwitch2Stop", Arc::from(SWITCH_PCT)), + ("SecondStopLoss", Arc::from(STEP_LEVEL)), + ("TimeToSwitchStop3", Arc::from(SWITCH_S)), + ("PriceToSwitchStop3", Arc::from(SWITCH_PCT)), + ("StopLoss3", Arc::from(STEP_LEVEL)), + ("TrailingPercent", Arc::from(TRAILING)), + ("TrailingEMA", Arc::from(TRAILING_EMA)), + ("TakeProfit", Arc::from(TAKE_PROFIT)), + ("SellModifier", Arc::from(SELL_MODIFIER)), + ("StopLossModifier", Arc::from(STOP_MODIFIER)), + ("Add1minDelta", Arc::from(DELTA_ADD)), + ("Add5minDelta", Arc::from(DELTA_ADD)), + ("Add15minDelta", Arc::from(DELTA_ADD)), + ("AddHourlyDelta", Arc::from(DELTA_ADD)), + ("Add3hDelta", Arc::from(DELTA_ADD)), + ("Add24hDelta", Arc::from(DELTA_ADD)), + ("AddMarketDelta", Arc::from(DELTA_ADD)), + ("AddMarket24Delta", Arc::from(DELTA_ADD)), + ("AddBTCDelta", Arc::from(DELTA_ADD)), + ("AddBTC5mDelta", Arc::from(DELTA_ADD)), + ("AddBTC1mDelta", Arc::from(DELTA_ADD)), + ("AddMarkDelta", Arc::from(DELTA_ADD)), + ("AddPump1h", Arc::from(DELTA_ADD)), + ("AddDump1h", Arc::from(DELTA_ADD)), + ("AddPriceBug", Arc::from(DELTA_ADD)), + ]) + }) +} diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index df29b924..e361c41b 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -113,6 +113,7 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { vary_entry: false, vary_exit: true, locked: &locked, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 3, min_n: Some(4), seed: Some(7), @@ -218,6 +219,7 @@ fn the_holdout_is_scored_but_never_fitted_on() { vary_entry: false, vary_exit: true, locked: &locked, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 1, min_n: Some(3), seed: Some(1), @@ -250,6 +252,7 @@ fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { vary_entry: true, vary_exit: true, locked: &all, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 2, min_n: None, seed: Some(1), @@ -269,6 +272,7 @@ fn a_cancelled_run_answers_nothing_and_nothing_varied_answers_nothing() { let none: HashSet = HashSet::new(); let params = SearchParams { locked: &none, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), ..params }; let handle = SearchHandle::new(); @@ -351,6 +355,7 @@ fn a_shift_does_not_search_the_path_only_fields() { vary_entry: true, vary_exit: false, locked: &locked, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 1, min_n: None, seed: Some(1), @@ -431,6 +436,7 @@ fn a_search_holds_each_deals_own_value_and_reports_a_value_one_strategy_lacks() vary_entry: false, vary_exit: true, locked: &locked, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 3, min_n: Some(2), seed: Some(7), @@ -476,6 +482,7 @@ fn a_trade_floor_no_point_keeps_finds_nothing() { vary_entry: false, vary_exit: true, locked: &locked, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 3, min_n: Some(9), seed: Some(7), @@ -626,8 +633,20 @@ fn a_pair_move_reaches_what_no_single_move_does() { let target = (9, 6); let evaluate = |point: &Point| -> Option { let at = ( - grid_index(price, point, &start).expect("price"), - grid_index(add, point, &start).expect("add"), + grid_index( + crate::db::tuner::ticks::search::test_grids::legacy(), + price, + point, + &start, + ) + .expect("price"), + grid_index( + crate::db::tuner::ticks::search::test_grids::legacy(), + add, + point, + &start, + ) + .expect("add"), ); let mut tally = Tally::default(); tally.push(if at == target { @@ -642,6 +661,7 @@ fn a_pair_move_reaches_what_no_single_move_does() { let order = [price, add]; let walked = descend( Point::new(), + crate::db::tuner::ticks::search::test_grids::legacy(), &order, &order, &coupled::Coupling::none(), @@ -654,8 +674,18 @@ fn a_pair_move_reaches_what_no_single_move_does() { .expect("not stopped"); assert_eq!( ( - grid_index(price, &walked.point, &start), - grid_index(add, &walked.point, &start) + grid_index( + crate::db::tuner::ticks::search::test_grids::legacy(), + price, + &walked.point, + &start + ), + grid_index( + crate::db::tuner::ticks::search::test_grids::legacy(), + add, + &walked.point, + &start + ) ), (Some(9), Some(6)) ); @@ -664,6 +694,7 @@ fn a_pair_move_reaches_what_no_single_move_does() { // Without the pairs the walk stays where it began. let alone = descend( Point::new(), + crate::db::tuner::ticks::search::test_grids::legacy(), &order, &[], &coupled::Coupling::none(), @@ -688,10 +719,23 @@ fn a_perturbed_start_stays_near_the_base() { for restart in 1..200 { let mut state = restart_seed(7, restart); let mut point = Point::new(); - perturb(&mut point, &order, &start, &mut state); + perturb( + &mut point, + crate::db::tuner::ticks::search::test_grids::legacy(), + &order, + &start, + &mut state, + ); assert!((1..=2).contains(&point.len()), "{point:?}"); for f in order { - if let Some(at) = point.get(f.key).and_then(|_| grid_index(f, &point, &start)) { + if let Some(at) = point.get(f.key).and_then(|_| { + grid_index( + crate::db::tuner::ticks::search::test_grids::legacy(), + f, + &point, + &start, + ) + }) { assert!(at.abs_diff(start[f.key]) <= 3, "{} at {at}", f.key); } } @@ -727,6 +771,7 @@ fn a_search_that_no_point_can_keep_the_corridor_of_says_so() { vary_entry: true, vary_exit: false, locked: &locked, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), restarts: 2, min_n: Some(1), seed: Some(7), diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs index 86671758..57b9f670 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs @@ -3,13 +3,23 @@ //! searches a selection — the Delta Modifiers section alone — every other field locked at each strategy's own //! value — so what the section can add over the strategies as they stand is read off a real //! replica, with no window. `MOON_TICKS_SEARCH_RESTARTS` sets the restarts (10 by default). +//! +//! The grids are the axis' own automatic ranges (`params::range`) over the live strategies of +//! this machine and the searched strategy's values, cut into `MOON_TICKS_STEPS` steps (the +//! axis default when unset); `MOON_TICKS_GRIDS=legacy` searches the ladders the axis used before +//! 2026-09-25 instead, so the two can be held against each other on the same deals. +//! `MOON_TICKS_SEARCH_ALL=1` searches the whole exit rather than the Delta Modifiers section. use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Instant; use crate::db::tuner::threshold_search::SearchHandle; +use crate::db::tuner::ticks::ParamKind; use crate::db::tuner::ticks::params::ParamSection; +use crate::db::tuner::ticks::params::range::{ + Grids, Population, TickRange, field_span, resolve, steps_of, +}; pub(super) use crate::db::tuner::ticks::search::PreparedDeal; use crate::db::tuner::ticks::search::{ DEFAULT_MAX_PASSES, SearchParams, clip_to_horizon, common_horizon_ms, suggest, variant_tally, @@ -61,9 +71,10 @@ fn run_one(mut deals: Vec, kind: &str, defaults: &HashMap = TICK_PARAMS .iter() - .filter(|f| f.section != ParamSection::DeltaModifiers) + .filter(|f| !whole_exit && f.section != ParamSection::DeltaModifiers) .map(|f| f.key.to_string()) .collect(); let restarts = std::env::var("MOON_TICKS_SEARCH_RESTARTS") @@ -71,6 +82,12 @@ fn run_one(mut deals: Vec, kind: &str, defaults: &HashMap, kind: &str, defaults: &HashMap, kind: &str, defaults: &HashMap = TICK_PARAMS @@ -126,3 +150,42 @@ fn run_one(mut deals: Vec, kind: &str, defaults: &HashMap eprintln!(" no answer: {miss:?}"), } } + +/// The axis' automatic grids for one strategy: the live strategies of `kind` on this machine, the +/// strategy's own values as the selection, `MOON_TICKS_STEPS` steps per field. +fn auto_grids(kind: &str, own: &HashMap, defaults: &HashMap) -> Grids { + let deps = crate::feed::strategy_deps::FieldDeps::bundled(); + let numbers: Vec<&'static str> = TICK_PARAMS + .iter() + .filter(|f| f.kind == ParamKind::Num) + .map(|f| f.key) + .collect(); + let mut keys: Vec = numbers.iter().map(|k| k.to_string()).collect(); + for key in &numbers { + keys.extend(deps.conditions_of(key).map(str::to_string)); + } + let live = crate::db::tuner::live_strategies(&keys); + let population = Population::of(&live, defaults, &deps); + let steps = steps_of( + std::env::var("MOON_TICKS_STEPS") + .ok() + .and_then(|v| v.parse().ok()), + ); + let kinds = [kind.to_string()]; + let mut grids = Grids::default(); + for key in numbers { + let default = defaults.get(&key.to_ascii_lowercase()).copied(); + let selected: Vec = own + .get(key) + .and_then(|v| v.trim().replace(',', ".").parse::().ok()) + .or(default) + .into_iter() + .collect(); + let span = field_span(&population.values(&kinds, key), default, &selected); + let points = resolve(span.as_ref(), &TickRange::default(), false, steps).points; + if !points.is_empty() { + grids.insert(key, points); + } + } + grids +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs index 99888720..d6f76fcc 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs @@ -271,6 +271,16 @@ impl AnalyticsView { window, cx, ); + // The ranges' placeholders follow the steps on Enter or when the box loses focus — the + // box's commit repaints the grid; a search started before takes the steps as typed. + let steps_input = self.ticks_text_input( + "x-cfg-steps", + self.ticks.steps.clone(), + moon_core::db::tuner::ticks::params::range::DEFAULT_STEPS.to_string(), + |this, value| this.ticks.steps = value, + window, + cx, + ); let train_pct = self.ticks.train_pct; let tr_view = cx.entity(); let tr_items = crate::panels::radio_items( @@ -367,6 +377,20 @@ impl AnalyticsView { p, cx, )) + .child(popup_row( + t!("analytics.ticks.cfg_steps").to_string(), + Some( + t!( + "analytics.ticks.cfg_steps_tip", + min = moon_core::db::tuner::ticks::params::range::MIN_STEPS, + max = moon_core::db::tuner::ticks::params::range::MAX_STEPS + ) + .to_string(), + ), + box_of("tun-cfg-steps-x", &steps_input, 76.0), + p, + cx, + )) .child(popup_section( t!("analytics.ticks.cfg_entry_section").to_string(), p, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index c85a17b8..fe7043d5 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -1,7 +1,9 @@ //! The parameter grid of the "Entry/Exit" axis, laid out as the "By filter" grid is: a tick per //! field that admits it to the search (the header's tick admits them all), the field's name — //! a click selects it for "Search" on one field — the value the selected strategies hold, which -//! a click sends to В1, and the two variant columns with the copy arrows and the clear crosses. +//! a click sends to В1, the variant column with its clear crosses, and the search range of each +//! number field — from, to, step and the reset (`ranges.rs`) — where the second variant column +//! stood until 2026-09-25. //! //! The rows come by the strategy editor's sections (`sections.rs`) — Strategy settings, Stops, //! Sell order, SellShot, SellSpread, Delta Modifiers — each with every knob and every field a @@ -27,18 +29,20 @@ use moon_ui::{ use rust_i18n::t; use super::super::super::AnalyticsView; -use super::super::shared::{N_VAR, TunerKind, collapse_caret, glyph_btn}; +use super::super::shared::{TunerKind, collapse_caret, glyph_btn}; use super::sections::{GridSection, RowRole, layout}; use super::state::{NowValue, TicksData}; use crate::design; use crate::design::{moon, moon_alpha}; -use moon_core::db::tuner::ticks::params::{ParamGroup, ParamSection, TickParam}; +use moon_core::db::tuner::ticks::params::{ParamGroup, ParamKind, ParamSection, TickParam}; /// Width of the strategy and variant cells, font-scaled px. const CELL_W: f32 = 60.0; /// Left inset of a field row, ui px: its tick sits under its section's tick, past the caret, /// so the row reads as inside the section. const ROW_INDENT: f32 = 30.0; +/// Id prefix of the variant cells' boxes in `TicksState::inputs`. +pub(super) const VARIANT_INPUT_PREFIX: &str = "v:"; impl AnalyticsView { /// The grid panel: the shared toolbar (title, Copy, Save), the search row, then the @@ -182,13 +186,13 @@ impl AnalyticsView { ) } - /// The column headings: the master tick over every live knob, field · strategy · В1 → ✕ · - /// В2 ← ✕. + /// The column headings: the master tick over every live knob, field · strategy · В1 ✕ · + /// from · to · step and the reset of every range. fn ticks_grid_header( &self, keys: Vec<&'static str>, p: MoonPalette, - cx: &Context, + cx: &mut Context, ) -> AnyElement { let (all_on, some_on) = self.ticks_tick_state(&keys); let cell = |text: String| { @@ -244,44 +248,20 @@ impl AnalyticsView { }), ) .child(cell(t!("analytics.tuner.strat_chip").to_string())); - for vi in 0..N_VAR { - head = head - .child(cell(t!("analytics.ticks.var_n", n = vi + 1).to_string())) - // The only two copy buttons, both "the WHOLE column": → carries В1 into В2, ← - // В2 into В1. Rows keep a matching spacer. - .child(if vi == 0 { - glyph_btn( - "an-ticks-cp-col", - "→", - t!("analytics.time.tip_to_v2").to_string(), - p.amber, - p, - cx, - ) - .on_click(cx.listener(|this, _, _, cx| this.ticks_copy_variant(0, 1, cx))) - } else { - glyph_btn( - "an-ticks-cpb-col", - "←", - t!("analytics.time.tip_to_v1").to_string(), - p.amber, - p, - cx, - ) - .on_click(cx.listener(|this, _, _, cx| this.ticks_copy_variant(1, 0, cx))) - }) - .child( - glyph_btn( - SharedString::from(format!("an-ticks-clr-col-{vi}")), - "✕", - t!("analytics.time.tip_clear_all").to_string(), - p.orange, - p, - cx, - ) - .on_click(cx.listener(move |this, _, _, cx| this.ticks_clear_variant(vi, cx))), - ); - } + head = head + .child(cell(t!("analytics.ticks.var_n", n = 1).to_string())) + .child( + glyph_btn( + "an-ticks-clr-col", + "✕", + t!("analytics.time.tip_clear_all").to_string(), + p.orange, + p, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.ticks_clear_variant(cx))), + ) + .child(self.ticks_range_header(cx)); head.into_any_element() } @@ -341,7 +321,7 @@ impl AnalyticsView { first: bool, noted: &mut Vec, p: MoonPalette, - cx: &Context, + cx: &mut Context, ) -> AnyElement { let title = crate::strategies::sections::section_display_title( section.section.schema_title(), @@ -409,6 +389,14 @@ impl AnalyticsView { let id = format!("{:?}", section.section); let which = section.section; let collapsed = !self.ticks.open_sections.contains(&which); + // The section's reset takes every number knob of it back to its automatic range. + let numbers: Vec<&'static str> = section + .knobs() + .filter(|k| k.kind == ParamKind::Num) + .map(|k| k.key) + .collect(); + let reset = self.ticks_range_section_reset(&id, numbers, cx); + let has_note = note.is_some(); h_flex() .w_full() .px(design::ui_px(cx, 8.0)) @@ -471,12 +459,14 @@ impl AnalyticsView { .child(note), ) }) + .when(!has_note, |el| el.child(div().flex_1())) + .child(reset) .into_any_element() } /// A field the search does not turn: a greyed, disabled tick, the name with why in its - /// tooltip, the strategies' value, and blanks where the variant cells stand, so the columns - /// stay in line. + /// tooltip, the strategies' value, and blanks where the variant cell and the range stand, so + /// the columns stay in line. fn ticks_fixed_row( &self, key: &str, @@ -547,17 +537,16 @@ impl AnalyticsView { .text_color(moon_alpha(p.text_muted, 0.8)) .child(value), ); - for _ in 0..N_VAR { - row = row - .child(div().w(design::font_w_px(cx, CELL_W)).flex_none()) - .child(div().w(design::ui_px(cx, 12.0)).flex_none()) - .child(div().w(design::ui_px(cx, 12.0)).flex_none()); - } + row = row + .child(div().w(design::font_w_px(cx, CELL_W)).flex_none()) + .child(div().w(design::ui_px(cx, 12.0)).flex_none()) + .child(self.ticks_range_blank(cx)); row.into_any_element() } - /// One field: its tick, its name, the strategies' value, an input per variant with its - /// clear cross. + /// One field: its tick, its name, the strategies' value, the variant's input with its clear + /// cross, and its search range — a number's; a switch or a list has no range, and a blank + /// keeps the columns in line. fn ticks_field_row( &mut self, key: &'static str, @@ -569,9 +558,10 @@ impl AnalyticsView { let read = super::model_cfg::current().entry_method.reads(key); let selected = self.ticks.sel_field == Some(key); let on = !self.ticks.locked.contains(key); - let inputs: Vec> = (0..N_VAR) - .map(|i| self.ticks_cell_input(i, key, window, cx)) - .collect(); + let input = self.ticks_cell_input(key, window, cx); + let is_number = moon_core::db::tuner::ticks::TICK_PARAMS + .iter() + .any(|f| f.key == key && f.kind == ParamKind::Num); let mut row = h_flex() .id(SharedString::from(format!("an-ticks-field-{key}"))) .w_full() @@ -653,7 +643,7 @@ impl AnalyticsView { .on_click(cx.listener(move |this, _, _, cx| { this.ticks.locked.insert(key.to_string()); this.persist_ticks_settings(cx); - this.ticks_set_cell(0, key, value.clone(), cx); + this.ticks_set_cell(key, value.clone(), cx); })) .into_any_element(), Some(NowValue::Differs) => div() @@ -672,44 +662,47 @@ impl AnalyticsView { .child("—") .into_any_element(), }); - for (vi, input) in inputs.iter().enumerate() { - row = row - .child( - div() - .w(design::font_w_px(cx, CELL_W)) - .flex_none() - .font_family(design::mono()) - .child( - MoonInput::new(SharedString::from(format!("an-ticks-in-v{vi}-{key}"))) - .state(input) - .size(design::dense_input_size(cx)), - ), + row = row + .child( + div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .font_family(design::mono()) + .child( + MoonInput::new(SharedString::from(format!("an-ticks-in-v-{key}"))) + .state(&input) + .size(design::dense_input_size(cx)), + ), + ) + .child( + glyph_btn( + SharedString::from(format!("an-ticks-clr-{key}")), + "✕", + t!("analytics.time.tip_clear").to_string(), + p.orange, + p, + cx, ) - // Under the header's copy arrow. - .child(div().w(design::ui_px(cx, 12.0)).flex_none()) - .child( - glyph_btn( - SharedString::from(format!("an-ticks-clr-{vi}-{key}")), - "✕", - t!("analytics.time.tip_clear").to_string(), - p.orange, - p, - cx, - ) - .on_click(cx.listener(move |this, _, _, cx| { - this.ticks_set_cell(vi, key, String::new(), cx) - })), - ); - } + .on_click( + cx.listener(move |this, _, _, cx| this.ticks_set_cell(key, String::new(), cx)), + ), + ) + .child(if is_number { + self.ticks_range_cells(key, p, window, cx) + } else { + self.ticks_range_blank(cx) + }); row.into_any_element() } /// Set one variant cell from outside its box — the strategy chip, a row's cross — and have /// the box show it. - fn ticks_set_cell(&mut self, index: usize, key: &str, value: String, cx: &mut Context) { - self.set_ticks_variant(index, key, value, cx); + fn ticks_set_cell(&mut self, key: &str, value: String, cx: &mut Context) { + self.set_ticks_variant(key, value, cx); // The box is recreated from the stored value on the next frame. - self.ticks.inputs.remove(&format!("v{index}:{key}")); + self.ticks + .inputs + .remove(&format!("{VARIANT_INPUT_PREFIX}{key}")); cx.notify(); } @@ -717,19 +710,15 @@ impl AnalyticsView { /// kept across repaints; a change stores the value and rescores the columns. fn ticks_cell_input( &mut self, - index: usize, key: &'static str, window: &mut Window, cx: &mut Context, ) -> Entity { - let id = format!("v{index}:{key}"); + let id = format!("{VARIANT_INPUT_PREFIX}{key}"); if let Some(state) = self.ticks.inputs.get(&id) { return state.clone(); } - let value = self.ticks.variants[index] - .get(key) - .cloned() - .unwrap_or_default(); + let value = self.ticks.variant.get(key).cloned().unwrap_or_default(); let state = cx.new(|cx| MoonInputState::new(window, cx).default_value(value)); cx.subscribe_in( &state, @@ -742,10 +731,8 @@ impl AnalyticsView { | MoonInputEvent::PressEnter { .. } ) { let value = state.read(cx).value().to_string(); - if this.ticks.variants[index].get(key).map(String::as_str) - != Some(value.as_str()) - { - this.set_ticks_variant(index, key, value, cx); + if this.ticks.variant.get(key).map(String::as_str) != Some(value.as_str()) { + this.set_ticks_variant(key, value, cx); } if !matches!(ev, MoonInputEvent::Change) { cx.notify(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 7809c60b..a19c00ba 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -1,6 +1,7 @@ //! Background loads of the "Entry/Exit" axis, in two stages. //! -//! Stage A reads the scope's deals and the grid's "now" values off the database. Its +//! Stage A reads the scope's deals, the grid's "now" values and the search ranges' automatic +//! spans (over the live strategies, `params::range`) off the database. Its //! completion resolves, on the UI thread, where each deal's prints live — the core's exchange //! key and the coin's market, which only the live market source knows — and publishes the rows //! at once, without their tape (stage B). Stage C then asks the replay worker for the held tape of every row in @@ -12,7 +13,7 @@ //! //! This file only ever WRITES `TicksState`; the rendering only reads it. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::mpsc; use std::time::Duration; @@ -27,10 +28,11 @@ use crate::analytics::bg::ReadLane; use crate::analytics::refresh::{CatchUpOutcome, report_result_is_stale}; use moon_core::db::ReadFail; use moon_core::db::order_traces::{TraceEntry, read_many}; +use moon_core::db::tuner::ticks::params::range::{FieldSpan, Population, field_span}; use moon_core::db::tuner::ticks::unmodelled::watched_keys; use moon_core::db::tuner::ticks::{ - Deal, DealsRead, EntryParams, ModelSettings, OwnLines, deltas, entry_model_for, infer_tick, - model_window, params, prepare_deal, required_spans, verify, + Deal, DealsRead, EntryParams, ModelSettings, OwnLines, ParamKind, TICK_PARAMS, deltas, + entry_model_for, infer_tick, model_window, params, prepare_deal, required_spans, verify, }; use moon_core::db::tuner::{strategy_current_values, strategy_values_at}; use moon_core::feed::report_traces::ArchivedLineKind; @@ -49,8 +51,8 @@ use moon_core::market::trade_replay::{ const HELD_ANSWER_WAIT: Duration = Duration::from_secs(240); /// What stage A brings back: the deals, the grid's "now" values, the strategies' own values, the -/// exit fields outside the model they switch on, the selected strategies' values and the rules -/// all of it was read under — the grid chooses its rows by them. +/// exit fields outside the model they switch on, the selected strategies' values, the rules all +/// of it was read under — the grid chooses its rows by them — and the search ranges' spans. type StageA = ( Result, HashMap, @@ -58,14 +60,18 @@ type StageA = ( Arc, Vec, FieldDeps, + Arc>, ); -/// What stage B publishes beside the rows: the strategies' values and the grid's layout. +/// What stage B publishes beside the rows: the strategies' values, the grid's layout and the +/// search ranges' spans and integer fields. struct ScopeView { now: HashMap, own: OwnValues, unmodelled: Arc, grid: Arc<[super::sections::GridSection]>, + spans: Arc>, + integers: Arc>, } impl AnalyticsView { @@ -184,9 +190,20 @@ impl AnalyticsView { &defaults, &deps, ); - (deals, now, own, Arc::new(unmodelled), selected, deps) + let kinds: Vec = deals + .as_ref() + .map(|read| { + let mut kinds: Vec = + read.deals.iter().map(|d| d.kind.clone()).collect(); + kinds.sort(); + kinds.dedup(); + kinds + }) + .unwrap_or_default(); + let spans = Arc::new(range_spans(&kinds, &selected, &defaults, &deps)); + (deals, now, own, Arc::new(unmodelled), selected, deps, spans) }, - move |this, (deals, now, own, unmodelled, selected, deps): StageA, cx| { + move |this, (deals, now, own, unmodelled, selected, deps, spans): StageA, cx| { if this.ticks.seq != req { return; } @@ -208,6 +225,9 @@ impl AnalyticsView { return; } }; + let integers = Arc::new(super::sections::integer_keys( + this.backend.read(cx).session.store(), + )); let grid = super::sections::grid_for( this.backend.read(cx).session.store(), &read.deals, @@ -228,6 +248,8 @@ impl AnalyticsView { own, unmodelled, grid, + spans, + integers, }, addresses, cx, @@ -342,6 +364,8 @@ impl AnalyticsView { own: scope.own, unmodelled: scope.unmodelled, grid: scope.grid, + spans: scope.spans, + integers: scope.integers, }; data.retain_within_cap(); data.refresh_summary(); @@ -652,6 +676,60 @@ fn now_values( /// A selected strategy's current values, by `(strategy_id, core)`. type SelectedValues = ((i64, Option), Arc>); +/// Each number knob's automatic search span: the live strategies of the scope's `kinds`, read +/// once per state of the strategies file (`live_strategies`), under the field rules `deps`, +/// widened to the schema default and the selected strategies' values — a strategy that leaves a +/// field out holds its default. A field nothing is known of is left out. +fn range_spans( + kinds: &[String], + selected: &[SelectedValues], + defaults: &HashMap, + deps: &FieldDeps, +) -> HashMap<&'static str, FieldSpan> { + let numbers: Vec<&'static str> = TICK_PARAMS + .iter() + .filter(|f| f.kind == ParamKind::Num) + .map(|f| f.key) + .collect(); + // The knobs and every field their rules read, so "in effect" is asked of real values. + let mut keys: Vec = numbers.iter().map(|k| k.to_string()).collect(); + for key in &numbers { + for condition in deps.conditions_of(key) { + if !keys.iter().any(|k| k.eq_ignore_ascii_case(condition)) { + keys.push(condition.to_string()); + } + } + } + let started = std::time::Instant::now(); + let live = moon_core::db::tuner::live_strategies(&keys); + let population = Population::of(&live, defaults, deps); + let spans: HashMap<&'static str, FieldSpan> = numbers + .iter() + .filter_map(|&key| { + let default = defaults.get(&key.to_ascii_lowercase()).copied(); + let own: Vec = selected + .iter() + .filter_map(|(_, values)| { + values + .get(key) + .and_then(|text| text.trim().replace(',', ".").parse::().ok()) + .or(default) + }) + .collect(); + let span = field_span(&population.values(kinds, key), default, &own)?; + Some((key, span)) + }) + .collect(); + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks ranges: {} span(s) over {} live strategies of kinds {kinds:?} in {} ms", + spans.len(), + live.len(), + started.elapsed().as_millis() + ); + spans +} + /// Every strategy of `deals` as it stands now, read once per `(strategy_id, core)` — the base /// each deal's variants run over ([`TicksData::own`]). A strategy that cannot be read gets an /// empty map, and its deals read every field at its default, as the grid's "now" does. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index d6f0f09c..9991ff17 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -43,6 +43,7 @@ mod grid; mod lags; mod load; pub(in crate::analytics) mod model_cfg; +mod ranges; pub(in crate::analytics::tuner) mod rows; mod sections; pub(in crate::analytics) mod state; @@ -496,7 +497,7 @@ impl AnalyticsView { /// The KPI matrix: the rows fit for the search as the baseline — captioned with how many of /// the covered ones that is, the ✓ shares of both groups (the model's own account of itself) - /// and the exit horizon — then В1 and В2 over the rows whose tape is in memory. The whole + /// and the exit horizon — then В1 over the rows whose tape is in memory. The whole /// scope is not a column: the axis works on the fit rows alone. An untouched variant is the /// strategy as it stands, and shows the baseline. fn ticks_kpi(&self, p: MoonPalette, cx: &Context) -> AnyElement { @@ -538,25 +539,20 @@ impl AnalyticsView { let base = VarLabel::with_sub(t!("analytics.ticks.subset").to_string(), subset_sub); let baseline: Option = self.ticks.kpi.data().and_then(|k| k.first().cloned()); - let mut labels = Vec::with_capacity(self.ticks.var_stats.len()); let mut stats: Vec = baseline.iter().cloned().collect(); - for (i, var) in self.ticks.var_stats.iter().enumerate() { - let title = t!("analytics.ticks.var_n", n = i + 1).to_string(); - let Some(var) = var else { - labels.push(VarLabel::with_sub( - title, - t!("analytics.ticks.var_untouched").to_string(), - )); + let title = t!("analytics.ticks.var_n", n = 1).to_string(); + let label = match &self.ticks.var_stats { + None => { stats.extend(baseline.iter().cloned()); - continue; - }; - let mut sub = t!( - "analytics.ticks.var_sub", - n = var.n, - m = self.ticks.var_n.max(replayable) - ) - .to_string(); - if i == 0 { + VarLabel::with_sub(title, t!("analytics.ticks.var_untouched").to_string()) + } + Some(var) => { + let mut sub = t!( + "analytics.ticks.var_sub", + n = var.n, + m = self.ticks.var_n.max(replayable) + ) + .to_string(); if let Some((holdout, open)) = self .ticks .last_result @@ -576,10 +572,11 @@ impl AnalyticsView { sub = format!("{sub} · {}", t!("analytics.ticks.holdout_open", n = open)); } } + stats.push(var.clone()); + VarLabel::with_sub(title, sub) } - labels.push(VarLabel::with_sub(title, sub)); - stats.push(var.clone()); - } + }; + let labels = [label]; let state = match &self.ticks.kpi { crate::load_state::LoadState::NotReady => crate::load_state::LoadState::NotReady, crate::load_state::LoadState::Failed(e) => { @@ -631,21 +628,20 @@ fn column_title(col: &DealCol) -> String { } } -/// What the plan column shows for one deal: each variant's `(money, per cent)` where the variant -/// was scored, `None` in an outer slot for a variant not scored at all. +/// What the plan column shows for one deal: the variant's `(money, per cent)` where it was +/// scored, `None` in the outer slot while the variant is not scored at all. #[derive(Clone, Copy)] -struct PlanCell([Option>; 2]); +struct PlanCell(Option>); impl PlanCell { - /// The deal's plan under both variants, from the state the columns were scored into. + /// The deal's plan under the variant, from the state the column was scored into. fn of(state: &state::TicksState, uid: i64) -> Self { - Self(std::array::from_fn(|i| { + Self( state .var_stats - .get(i) - .and_then(|s| s.as_ref()) - .map(|_| state.plan[i].get(&uid).copied()) - })) + .as_ref() + .map(|_| state.plan.get(&uid).copied()), + ) } /// The cell's text, colour and tooltip: В1's result — per cent in percent mode, the @@ -659,18 +655,16 @@ impl PlanCell { None => "—".to_string(), }; let tip = || { - let parts: Vec = self - .0 - .iter() - .enumerate() - .filter_map(|(i, v)| { - v.map(|v| format!("{} {}", t!("analytics.ticks.var_n", n = i + 1), money(v))) - }) - .collect(); - (!parts.is_empty()) - .then(|| format!("{} · {}", parts.join(" · "), t!("analytics.ticks.plan_tip"))) + self.0.map(|v| { + format!( + "{} {} · {}", + t!("analytics.ticks.var_n", n = 1), + money(v), + t!("analytics.ticks.plan_tip") + ) + }) }; - match self.0[0] { + match self.0 { None => (String::new(), p.text_muted, tip()), Some(None) => ("—".to_string(), p.text_muted, tip()), Some(Some(v)) => ( diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/ranges.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/ranges.rs new file mode 100644 index 00000000..950a703a --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/ranges.rs @@ -0,0 +1,398 @@ +//! The search ranges of the "Entry/Exit" grid: "from", "to" and "step" beside each number knob, +//! and the square reset that takes a row, a section or the whole grid back to automatic. +//! +//! An empty cell is automatic, and shows what the search takes there greyed in as its +//! placeholder: the range the live strategies and the selection give the field +//! (`moon_core::db::tuner::ticks::params::range`), or — where another cell of the row is typed — +//! what that typed cell makes of it (a typed "to" cuts a new step out of the same steps per field). +//! A typed value is the user's and stays until the reset; the typed ranges persist with the +//! axis' settings (`WindowLayout::analytics_ticks`). A typed range the search cannot use — +//! "from" above "to", a step of zero, more points than a field may have, a step alone on a field +//! with no automatic range to cut it over — has its row framed +//! red, and the search takes the automatic range and says so in its status line. +//! +//! Nothing here is scored: a range moves only what the NEXT search tries (`variants.rs` resolves +//! the grids at its start), never a column or the table. + +use gpui::*; +use moon_ui::{ + MoonButton, MoonButtonIconSlot, MoonButtonVariant, MoonInput, MoonInputEvent, MoonInputState, + MoonPalette, h_flex, +}; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +use crate::design; +use crate::design::{moon, moon_alpha}; +use moon_core::db::tuner::ticks::params::range::{ + Grids, RangeError, Resolved, TickRange, resolve, spell_number, +}; +use moon_core::db::tuner::ticks::{ParamKind, TICK_PARAMS}; + +/// Width of one range cell, font-scaled px: five characters of a caption-sized mono figure +/// (`-0.75`, `0.001`, `1800`). +const RANGE_W: f32 = 40.0; +/// Gap between the range cells, ui px — tighter than the row's, so the three read as one group. +const RANGE_GAP: f32 = 3.0; +/// Id prefix of the range cells' boxes in `TicksState::inputs`. +const RANGE_INPUT_PREFIX: &str = "r:"; +/// The reset's icon: the undo arrow of the embedded MoonUI set — "back to what it was". +const RESET_ICON: &str = "icons/undo-2.svg"; + +/// One cell of a range. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Slot { + From, + To, + Step, +} + +impl Slot { + const ALL: [Slot; 3] = [Slot::From, Slot::To, Slot::Step]; + + fn id(self) -> &'static str { + match self { + Slot::From => "from", + Slot::To => "to", + Slot::Step => "step", + } + } + + fn of(self, range: &TickRange) -> Option { + match self { + Slot::From => range.from, + Slot::To => range.to, + Slot::Step => range.step, + } + } + + fn set(self, range: &mut TickRange, value: Option) { + match self { + Slot::From => range.from = value, + Slot::To => range.to = value, + Slot::Step => range.step = value, + } + } + + /// The heading of the cell's column. + fn title(self) -> String { + match self { + Slot::From => t!("analytics.ticks.range_from"), + Slot::To => t!("analytics.ticks.range_to"), + Slot::Step => t!("analytics.ticks.range_step"), + } + .to_string() + } +} + +/// A typed cell's number: empty is automatic, a comma reads as the decimal point; `Err` for text +/// that is not a finite number. +fn parse_cell(text: &str) -> Result, ()> { + let text = text.trim(); + if text.is_empty() { + return Ok(None); + } + text.replace(',', ".") + .parse::() + .ok() + .filter(|v| v.is_finite()) + .map(Some) + .ok_or(()) +} + +/// Width of the whole range block — three cells, their gaps and the reset — so a row without a +/// range and a heading keep the columns in line. +fn block_w(cx: &App) -> Pixels { + design::font_w_px(cx, RANGE_W) * 3.0 + + design::ui_px(cx, RANGE_GAP) * 3.0 + + px(design::dense_glyph_btn_w(cx)) +} + +impl AnalyticsView { + /// One field's grid as the search would take it now: its typed range over the automatic + /// one, under the steps-per-field setting. + fn ticks_resolved(&self, key: &str) -> Resolved { + let data = self.ticks.data.data(); + let typed = self.ticks.ranges.get(key).copied().unwrap_or_default(); + resolve( + data.and_then(|d| d.spans.get(key)), + &typed, + data.is_some_and(|d| d.integers.contains(key)), + self.ticks.steps_per_param(), + ) + } + + /// Every number knob's grid for a search, resolved from the ranges as they stand, and the + /// keys whose typed range was set aside for the automatic one. A field with no point — one + /// nothing is known of — gets no grid, and the search does not vary it. + pub(super) fn ticks_search_grids(&self) -> (Grids, Vec<&'static str>) { + let mut grids = Grids::default(); + let mut set_aside = Vec::new(); + for field in TICK_PARAMS.iter().filter(|f| f.kind == ParamKind::Num) { + let resolved = self.ticks_resolved(field.key); + if resolved.error.is_some() && !set_aside.contains(&field.key) { + set_aside.push(field.key); + } + if !resolved.points.is_empty() { + grids.insert(field.key, resolved.points); + } + } + (grids, set_aside) + } + + /// A number knob's range: the three cells, framed red while the typed range is set aside, + /// and the row's reset. + pub(super) fn ticks_range_cells( + &mut self, + key: &'static str, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let resolved = self.ticks_resolved(key); + let typed = self.ticks.ranges.get(key).copied().unwrap_or_default(); + let shown = resolved.shown; + let mut cells = h_flex() + .id(SharedString::from(format!("an-ticks-range-{key}"))) + .flex_none() + .items_center() + .gap(design::ui_px(cx, RANGE_GAP)) + .tooltip(crate::panels::common::text_tooltip(range_tip( + &resolved, + self.ticks + .data + .data() + .is_some_and(|d| d.integers.contains(key)), + ))); + for slot in Slot::ALL { + // What an empty cell stands for: the value the search takes there. + let placeholder = match (shown, slot) { + (None, _) => "—".to_string(), + (Some(s), Slot::From) => spell_number(s.from), + (Some(s), Slot::To) => spell_number(s.to), + (Some(s), Slot::Step) if s.step > 0.0 => spell_number(s.step), + (Some(_), Slot::Step) => "—".to_string(), + }; + let input = self.ticks_range_input(key, slot, &typed, placeholder, window, cx); + let bad = parse_cell(input.read(cx).value().as_ref()).is_err(); + cells = cells.child( + div() + .w(design::font_w_px(cx, RANGE_W)) + .flex_none() + .font_family(design::mono()) + .rounded(design::ui_px(cx, 3.0)) + .border_1() + .border_color(if bad || resolved.error.is_some() { + moon(p.red) + } else { + moon_alpha(p.border, 0.0) + }) + .child( + MoonInput::new(SharedString::from(format!( + "an-ticks-range-in-{}-{key}", + slot.id() + ))) + .state(&input) + .size(design::dense_input_size(cx)), + ), + ); + } + cells + .child(self.ticks_reset_button( + SharedString::from(format!("an-ticks-range-reset-{key}")), + vec![key], + t!("analytics.ticks.range_reset").to_string(), + cx, + )) + .into_any_element() + } + + /// The blank a row without a range keeps where the range block stands. + pub(super) fn ticks_range_blank(&self, cx: &App) -> AnyElement { + div().w(block_w(cx)).flex_none().into_any_element() + } + + /// The range columns' headings and the reset of every range, for the grid's header row. + pub(super) fn ticks_range_header(&self, cx: &mut Context) -> AnyElement { + let keys: Vec<&'static str> = TICK_PARAMS + .iter() + .filter(|f| f.kind == ParamKind::Num) + .map(|f| f.key) + .collect(); + let mut head = h_flex() + .flex_none() + .items_center() + .gap(design::ui_px(cx, RANGE_GAP)); + for slot in Slot::ALL { + head = head.child( + div() + .w(design::font_w_px(cx, RANGE_W)) + .flex_none() + .text_center() + .truncate() + .child(slot.title()), + ); + } + head.child(self.ticks_reset_button( + "an-ticks-range-reset-all".into(), + keys, + t!("analytics.ticks.range_reset_all").to_string(), + cx, + )) + .into_any_element() + } + + /// A section heading's reset of its knobs' ranges, right-aligned under the range column. + pub(super) fn ticks_range_section_reset( + &self, + id: &str, + keys: Vec<&'static str>, + cx: &mut Context, + ) -> AnyElement { + h_flex() + .w(block_w(cx)) + .flex_none() + .justify_end() + .child(self.ticks_reset_button( + SharedString::from(format!("an-ticks-range-reset-sec-{id}")), + keys, + t!("analytics.ticks.range_reset_section").to_string(), + cx, + )) + .into_any_element() + } + + /// The square reset of `keys`' ranges, muted and inert while none of them is typed. + fn ticks_reset_button( + &self, + id: SharedString, + keys: Vec<&'static str>, + tip: String, + cx: &mut Context, + ) -> AnyElement { + let typed = keys.iter().any(|k| self.ticks.ranges.contains_key(*k)); + MoonButton::new(id) + .size(design::dense_glyph_btn_size()) + .width(design::dense_glyph_btn_w(cx)) + .variant(MoonButtonVariant::Ghost) + .leading_icon(MoonButtonIconSlot::new(RESET_ICON)) + .tooltip(tip) + .disabled(!typed) + .on_click(cx.listener(move |this, _, _, cx| this.ticks_reset_ranges(&keys, cx))) + .render() + .into_any_element() + } + + /// Take `keys` back to their automatic ranges. + fn ticks_reset_ranges(&mut self, keys: &[&'static str], cx: &mut Context) { + let mut changed = false; + for key in keys { + changed |= self.ticks.ranges.remove(*key).is_some(); + for slot in Slot::ALL { + let id = range_input_id(key, slot); + self.ticks.inputs.remove(&id); + self.ticks.placeholders.remove(&id); + } + } + if changed { + self.persist_ticks_settings(cx); + } + cx.notify(); + } + + /// The box of one range cell, created on first use from the typed value and kept across + /// repaints; its placeholder follows what the search takes there. A change stores the value + /// at once — a search started before the box loses focus takes it — and the box's leaving or + /// Enter writes the settings. + fn ticks_range_input( + &mut self, + key: &'static str, + slot: Slot, + typed: &TickRange, + placeholder: String, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let id = range_input_id(key, slot); + let state = match self.ticks.inputs.get(&id) { + Some(state) => state.clone(), + None => { + let value = slot.of(typed).map(spell_number).unwrap_or_default(); + let state = cx.new(|cx| MoonInputState::new(window, cx).default_value(value)); + cx.subscribe_in( + &state, + window, + move |this, state, ev: &MoonInputEvent, _window, cx| { + if !matches!( + ev, + MoonInputEvent::Change + | MoonInputEvent::Blur + | MoonInputEvent::PressEnter { .. } + ) { + return; + } + // Text that is not a number keeps the slot as it was: the red frame says + // so, and the search does not read half a keystroke. + if let Ok(value) = parse_cell(state.read(cx).value().as_ref()) { + let mut range = this.ticks.ranges.get(key).copied().unwrap_or_default(); + slot.set(&mut range, value); + if range.is_auto() { + this.ticks.ranges.remove(key); + } else { + this.ticks.ranges.insert(key.to_string(), range); + } + } + if !matches!(ev, MoonInputEvent::Change) { + this.persist_ticks_settings(cx); + } + cx.notify(); + }, + ) + .detach(); + self.ticks.inputs.insert(id.clone(), state.clone()); + state + } + }; + // Set only when it moved: the box repaints on every set. + if self.ticks.placeholders.get(&id).map(String::as_str) != Some(placeholder.as_str()) { + state.update(cx, |state, cx| { + state.set_placeholder(placeholder.clone(), window, cx) + }); + self.ticks.placeholders.insert(id, placeholder); + } + state + } +} + +/// The key a range cell's box is kept under in `TicksState::inputs`. +fn range_input_id(key: &str, slot: Slot) -> String { + format!("{RANGE_INPUT_PREFIX}{}:{key}", slot.id()) +} + +/// The tooltip over a row's range: how many values the search tries, why a typed range is set +/// aside when it is, and — on a field the schema types as an integer — that typed values are +/// rounded to whole ones, so a box showing 1.5 is not taken for what the search tries. +fn range_tip(resolved: &Resolved, integer: bool) -> String { + let mut count = t!("analytics.ticks.range_points", n = resolved.points.len()).to_string(); + if integer { + count = format!("{count} · {}", t!("analytics.ticks.range_integer")); + } + let why = resolved.error.map(|error| match error { + RangeError::Inverted => t!("analytics.ticks.range_err_inverted"), + RangeError::BadStep => t!("analytics.ticks.range_err_step"), + RangeError::TooMany => t!( + "analytics.ticks.range_err_many", + n = moon_core::db::tuner::ticks::params::range::MAX_STEPS + ), + RangeError::NoEdges => t!("analytics.ticks.range_err_edges"), + }); + match (resolved.shown, why) { + (None, Some(why)) => format!("{why} · {}", t!("analytics.ticks.range_no_data")), + (None, None) => t!("analytics.ticks.range_no_data").to_string(), + (Some(_), Some(why)) => format!("{why} · {count}"), + (Some(_), None) => format!("{count} · {}", t!("analytics.ticks.range_hint")), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/ranges/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/ranges/tests.rs new file mode 100644 index 00000000..f71aef27 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/ranges/tests.rs @@ -0,0 +1,36 @@ +//! The range cells' reading of what was typed. + +use super::{RANGE_INPUT_PREFIX, Slot, parse_cell, range_input_id}; + +/// An empty cell is automatic; a comma is the decimal point; text that is no number is refused, +/// not read as zero. +#[test] +fn a_cell_reads_empty_as_automatic_and_refuses_what_is_no_number() { + assert_eq!(parse_cell(""), Ok(None)); + assert_eq!(parse_cell(" "), Ok(None)); + assert_eq!(parse_cell("1,5"), Ok(Some(1.5))); + assert_eq!(parse_cell("-0.2"), Ok(Some(-0.2))); + assert_eq!(parse_cell("1.5%"), Err(())); + assert_eq!(parse_cell("abc"), Err(())); + assert_eq!(parse_cell("inf"), Err(())); +} + +/// Each cell of each field keeps a box of its own, apart from the variant cells'. +#[test] +fn every_cell_has_its_own_box_id() { + let ids: Vec = Slot::ALL + .iter() + .map(|slot| range_input_id("SellPrice", *slot)) + .collect(); + assert_eq!(ids.len(), 3); + assert!(ids.iter().all(|id| id.starts_with(RANGE_INPUT_PREFIX))); + assert_ne!(ids[0], ids[1]); + assert_ne!( + range_input_id("SellPrice", Slot::From), + range_input_id("StopLoss", Slot::From) + ); + assert!( + !ids.iter() + .any(|id| id.starts_with(super::super::grid::VARIANT_INPUT_PREFIX)) + ); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs index f56a99a7..152bfd3e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs @@ -25,7 +25,7 @@ pub(in crate::analytics::tuner) fn order_for(state: &mut TicksState) -> &[usize] .filter(|&i| !state.only_fit || rows[i].fit()) .collect(); if let Some((key, desc)) = &state.sort { - sort_indices(rows, &state.plan[0], &mut order, key, *desc); + sort_indices(rows, &state.plan, &mut order, key, *desc); } state.order = Some(OrderCache { rows_rev: state.rows_rev, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 6d8f06f0..4d942869 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -216,20 +216,18 @@ fn covered_and_fetchable_count_what_the_captions_say() { fn variant_edits_fold_to_sorted_changes_and_empty_cells_clear() { let mut state = TicksState::default(); assert!(!state.has_changes()); - state.set_variant(0, "SellPrice", " 0.5 ".into()); - state.set_variant(0, "MShotPrice", "2".into()); - state.set_variant(1, "StopLoss", "-1".into()); + state.set_variant("SellPrice", " 0.5 ".into()); + state.set_variant("MShotPrice", "2".into()); assert_eq!( - state.variant_changes(0), + state.variant_changes(), vec![ ("MShotPrice".to_string(), "2".to_string()), ("SellPrice".to_string(), "0.5".to_string()), ] ); assert!(state.has_changes()); - state.set_variant(0, "MShotPrice", " ".into()); - assert_eq!(state.variant_changes(0).len(), 1, "a blank clears the cell"); - assert_eq!(state.variant_changes(1).len(), 1); + state.set_variant("MShotPrice", " ".into()); + assert_eq!(state.variant_changes().len(), 1, "a blank clears the cell"); } #[test] @@ -252,8 +250,8 @@ fn the_share_gate_answers_per_group_and_only_once_something_answered() { #[test] fn invalidate_stops_the_search_and_drops_the_variant_scores_but_keeps_the_edits() { let mut state = state(); - state.set_variant(0, "SellPrice", "1".into()); - state.var_stats[0] = Some(moon_core::db::tuner::VarStats::default()); + state.set_variant("SellPrice", "1".into()); + state.var_stats = Some(moon_core::db::tuner::VarStats::default()); let handle = moon_core::db::tuner::threshold_search::SearchHandle::new(); state.sugg = super::super::state::SuggState::Running { handle: handle.clone(), @@ -270,7 +268,7 @@ fn invalidate_stops_the_search_and_drops_the_variant_scores_but_keeps_the_edits( state.invalidate(); assert!(handle.is_cancelled()); assert!(matches!(state.sugg, super::super::state::SuggState::Idle)); - assert!(state.var_stats[0].is_none()); + assert!(state.var_stats.is_none()); assert!( state.last_result.is_none(), "the last search's holdout is of the previous scope's deals" diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs index 7f4f4376..211e3254 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs @@ -266,6 +266,42 @@ pub(in crate::analytics::tuner) fn schema_keys(store: &CoreStore) -> Vec seen.into_iter().map(str::to_string).collect() } +/// The number knobs every connected core's schema types as an integer (`Int32`, `Int64`) — +/// wherever it lists them at all: a typed range on such a field is cut to whole numbers +/// (`params::range::resolve`). A field one schema types otherwise is not among them. +pub(in crate::analytics::tuner) fn integer_keys(store: &CoreStore) -> HashSet<&'static str> { + let mut integer: HashSet<&'static str> = HashSet::new(); + let mut other: HashSet<&'static str> = HashSet::new(); + let knob = |name: &str| { + moon_core::db::tuner::ticks::TICK_PARAMS + .iter() + .find(|f| f.key.eq_ignore_ascii_case(name)) + .map(|f| f.key) + }; + for (_, core) in store.cores() { + let Some(schema) = core.schema.as_ref() else { + continue; + }; + for field in schema + .kinds + .iter() + .flat_map(|k| &k.sections) + .flat_map(|s| &s.fields) + { + let Some(key) = knob(&field.name) else { + continue; + }; + if field.type_name.starts_with("Int") { + integer.insert(key); + } else { + other.insert(key); + } + } + } + integer.retain(|key| !other.contains(key)); + integer +} + /// A signature of the schemas the store holds: which cores have one, at which revision. It moves /// whenever a core's schema arrives, changes or goes, and is independent of the order the store /// lists its cores in. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 944aae34..767d7ce4 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -6,17 +6,17 @@ //! Split from the rendering (`ticks/mod.rs`) like every other axis: the load paths write here, //! the render path only reads. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use gpui::Entity; use moon_ui::MoonInputState; -use super::super::shared::N_VAR; use super::tape::{PackedTape, PendingDeal}; use crate::load_state::LoadState; use moon_core::db::tuner::VarStats; use moon_core::db::tuner::threshold_search::SearchHandle; +use moon_core::db::tuner::ticks::params::range::{FieldSpan, TickRange}; use moon_core::db::tuner::ticks::params::{ParamGroup, ParamSection}; use moon_core::db::tuner::ticks::search::SearchResult; use moon_core::db::tuner::ticks::{Deal, Verdict, fit_for_search}; @@ -180,6 +180,12 @@ pub(in crate::analytics::tuner) struct TicksData { /// The parameter grid's rows by section (`sections::layout`), published with the rows so a /// layout never meets another scope's "now" values or kinds. pub(in crate::analytics::tuner) grid: Arc<[super::sections::GridSection]>, + /// Each number knob's automatic search span over the live strategies and the selection + /// (`params::range::field_span`), by key; a field nothing is known of is absent. + pub(in crate::analytics::tuner) spans: Arc>, + /// The number knobs the schema types as integers — their typed ranges are cut to whole + /// numbers. + pub(in crate::analytics::tuner) integers: Arc>, } /// What [`TicksData::tape_budget`] counts. @@ -316,13 +322,14 @@ impl Drop for TicksState { /// State of the "Entry/Exit" mode. pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) data: LoadState, - /// The variant columns' edits: field key to value in strategy spelling. An empty map is - /// an untouched column, drawn as the base. - pub(in crate::analytics::tuner) variants: [HashMap; N_VAR], - /// The KPI of each variant over the replayable rows, `None` until computed or while the - /// variant is untouched. - pub(in crate::analytics::tuner) var_stats: [Option; N_VAR], - /// How many replayable rows the variant KPIs were computed over, for their captions. + /// The variant column's edits (В1): field key to value in strategy spelling. An empty map is + /// an untouched column, drawn as the base. One column: the second one went on 2026-09-25, + /// its place in the grid taken by the search ranges. + pub(in crate::analytics::tuner) variant: HashMap, + /// The variant's KPI over the replayable rows, `None` until computed or while the variant is + /// untouched. + pub(in crate::analytics::tuner) var_stats: Option, + /// How many replayable rows the variant KPI was computed over, for its caption. pub(in crate::analytics::tuner) var_n: usize, /// Generation of the variant KPI recompute; a stale completion is dropped. pub(in crate::analytics::tuner) var_seq: u64, @@ -330,8 +337,17 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) var_task: Option>, /// The grid's and the row's input boxes, created lazily and kept across repaints. pub(in crate::analytics::tuner) inputs: HashMap>, + /// The placeholder each range cell's box was last given, by its id in `inputs` — set again + /// only when what the search takes there moved (`ranges.rs`). + pub(in crate::analytics::tuner) placeholders: HashMap, /// Unticked rows: held at base by the search, bar what a switch it turns on needs. Persisted. pub(in crate::analytics::tuner) locked: HashSet, + /// The search ranges typed over the automatic ones, by field key; a field absent is fully + /// automatic. Persisted. + pub(in crate::analytics::tuner) ranges: BTreeMap, + /// Steps per field the automatic ranges are cut into, as typed; empty = the default. + /// Persisted. + pub(in crate::analytics::tuner) steps: String, /// The field "Search" on one field varies — the one whose name was clicked last. pub(in crate::analytics::tuner) sel_field: Option<&'static str>, /// The search settings, as typed; all but the minimum trades persist. @@ -402,11 +418,11 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) tape_reading: bool, /// The trade pane under the table (`trade_pane.rs`). pub(in crate::analytics::tuner) trade: super::trade_pane::TradePane, - /// Each variant's result per deal, by `ReportUID`, as `(money in the sample's unit, per cent)` - /// — what the column counted for that deal (`variant_tally_by_deal`). A deal absent is one the variant makes - /// no trade of, or one outside the replayed sample. Scored with the columns, cleared with - /// them. - pub(in crate::analytics::tuner) plan: [HashMap; N_VAR], + /// The variant's result per deal, by `ReportUID`, as `(money in the sample's unit, per cent)` + /// — what the column counted for that deal (`variant_tally_by_deal`). A deal absent is one + /// the variant makes no trade of, or one outside the replayed sample. Scored with the + /// column, cleared with it. + pub(in crate::analytics::tuner) plan: HashMap, /// `sections::schema_signature` of the store when the latest load chose the keys of its /// "now" values — set when the load is ASKED, so a load already running under a new schema /// is not asked for again (`grid.rs`). @@ -422,13 +438,16 @@ impl Default for TicksState { fn default() -> Self { Self { data: LoadState::default(), - variants: Default::default(), - var_stats: Default::default(), + variant: HashMap::new(), + var_stats: None, var_n: 0, var_seq: 0, var_task: None, inputs: HashMap::new(), + placeholders: HashMap::new(), locked: HashSet::new(), + ranges: BTreeMap::new(), + steps: String::new(), sel_field: None, iters: String::new(), min_trades: String::new(), @@ -457,7 +476,7 @@ impl Default for TicksState { judged_under: None, tape_seq: 0, trade: Default::default(), - plan: Default::default(), + plan: HashMap::new(), keys_sig: None, schema_reload: None, open_sections: HashSet::new(), @@ -491,6 +510,16 @@ impl TicksState { self.locked = saved.locked.iter().cloned().collect(); self.trade.open = saved.trade_open; self.keep_corridor = !saved.allow_closer_corridor; + self.steps = saved + .steps_per_param + .map(|n| n.to_string()) + .unwrap_or_default(); + self.ranges = saved.ranges.clone(); + } + + /// Steps per field the automatic ranges are cut into, out of the typed box. + pub(in crate::analytics::tuner) fn steps_per_param(&self) -> u32 { + moon_core::db::tuner::ticks::params::range::steps_of(self.steps.trim().parse::().ok()) } /// The axis' settings as the layout persists them, the model's from their process-wide @@ -510,6 +539,8 @@ impl TicksState { trade_open: self.trade.open, allow_closer_corridor: !self.keep_corridor, min_tail_s: Some(super::tail::current_s()), + steps_per_param: number(&self.steps), + ranges: self.ranges.clone(), } } @@ -550,8 +581,8 @@ impl TicksState { self.order = None; self.var_seq = self.var_seq.wrapping_add(1); self.var_task = None; - self.var_stats = Default::default(); - self.plan = Default::default(); + self.var_stats = None; + self.plan.clear(); if let Some(data) = self.data.data_mut() { for row in &mut data.rows { if row.tape == TapeStatus::Fetching { @@ -583,11 +614,9 @@ impl TicksState { /// The variant's changes over the base as `(key, value)` pairs, sorted — what Save writes /// and what the KPI is computed for. - pub(in crate::analytics::tuner) fn variant_changes( - &self, - index: usize, - ) -> Vec<(String, String)> { - let mut out: Vec<(String, String)> = self.variants[index] + pub(in crate::analytics::tuner) fn variant_changes(&self) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = self + .variant .iter() .filter(|(_, v)| !v.trim().is_empty()) .map(|(k, v)| (k.clone(), v.trim().to_string())) @@ -596,22 +625,17 @@ impl TicksState { out } - /// Whether the first variant holds anything to write. + /// Whether the variant holds anything to write. pub(in crate::analytics::tuner) fn has_changes(&self) -> bool { - !self.variant_changes(0).is_empty() + !self.variant_changes().is_empty() } - /// Set one cell of a variant; an empty value clears it. - pub(in crate::analytics::tuner) fn set_variant( - &mut self, - index: usize, - key: &str, - value: String, - ) { + /// Set one cell of the variant; an empty value clears it. + pub(in crate::analytics::tuner) fn set_variant(&mut self, key: &str, value: String) { if value.trim().is_empty() { - self.variants[index].remove(key); + self.variant.remove(key); } else { - self.variants[index].insert(key.to_string(), value); + self.variant.insert(key.to_string(), value); } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs index 6d9130ab..67e4ddbe 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs @@ -1,8 +1,8 @@ //! The trade pane under the deal table: the selected deal drawn as its trade window draws it — //! the same view (`trade_window::Host::Embedded`), not a second chart — with the trades the -//! variant columns would have made beside the fact, В1 dashed and В2 dotted: the path each -//! variant's entry order walked, its fill and exit, and — under the window's MoonShot zone switch -//! — the corridor the model held around that order, placement by placement. +//! variant column would have made beside the fact, dashed: the path the variant's entry order +//! walked, its fill and exit, and — under the window's MoonShot zone switch — the corridor the +//! model held around that order, placement by placement. //! //! Folded by default, behind a rail like the one between the halves of the tab; folded, a click //! on a row selects nothing and nothing is built. Open, a click on a row shows that deal; a @@ -17,14 +17,14 @@ use gpui::prelude::FluentBuilder; use gpui::*; use moon_chart::frozen_overlay::{OverlayBand, OverlayTrade}; -use moon_chart::layers::{SEG_PATTERN_DASH, SEG_PATTERN_DOT}; +use moon_chart::layers::SEG_PATTERN_DASH; use moon_core::db::tuner::ticks::ExitKind; use moon_core::db::tuner::ticks::search::{clip_to_horizon, variant_picture}; use moon_ui::{MoonPalette, h_flex, v_flex}; use rust_i18n::t; use super::super::super::AnalyticsView; -use super::super::shared::{N_VAR, collapse_caret}; +use super::super::shared::collapse_caret; use crate::design; use crate::design::moon; use crate::trade_window::TradeWindowView; @@ -48,13 +48,8 @@ pub(in crate::analytics::tuner) struct TradePane { model_seq: u64, } -/// The pen of a variant's modelled trade: В1 dashed, В2 dotted — the fact keeps its solid lines. -fn variant_pattern(index: usize) -> f32 { - match index { - 0 => SEG_PATTERN_DASH, - _ => SEG_PATTERN_DOT, - } -} +/// The pen of the variant's modelled trade: dashed — the fact keeps its solid lines. +const VARIANT_PATTERN: f32 = SEG_PATTERN_DASH; impl AnalyticsView { /// Fold or open the pane, and remember it. Folding drops the chart; opening shows the deal @@ -126,8 +121,8 @@ impl AnalyticsView { ); } - /// Replay every touched variant on the pane's deal and hand the trades to its view. Nothing - /// to replay — no view, no tape in memory for the deal, no variant touched — hands it none. + /// Replay the variant on the pane's deal and hand its trade to the view. Nothing to replay — + /// no view, no tape in memory for the deal, an untouched variant — hands it none. pub(in crate::analytics::tuner) fn ticks_refresh_model_trades( &mut self, cx: &mut Context, @@ -137,8 +132,7 @@ impl AnalyticsView { }; self.ticks.trade.model_seq = self.ticks.trade.model_seq.wrapping_add(1); let seq = self.ticks.trade.model_seq; - let changes: Vec> = - (0..N_VAR).map(|i| self.ticks.variant_changes(i)).collect(); + let changes = self.ticks.variant_changes(); let job = self.ticks.data.data().and_then(|data| { let uid = self.ticks.trade.uid?; let row = data @@ -146,7 +140,7 @@ impl AnalyticsView { .iter() .find(|r| r.deal.report_uid == uid) .filter(|r| r.fit())?; - if changes.iter().all(Vec::is_empty) { + if changes.is_empty() { return None; } Some(( @@ -166,7 +160,7 @@ impl AnalyticsView { let is_short = pending.deal.is_short; cx.spawn(async move |this, cx| { let executor = cx.update(|cx| cx.background_executor().clone()); - let pictures = executor + let picture = executor .spawn(async move { // Unpacked here, off the UI thread, and cut as the columns cut it, so the // picture shows what the column counted. @@ -174,21 +168,13 @@ impl AnalyticsView { if let Some(horizon_ms) = horizon_ms { clip_to_horizon(std::slice::from_mut(&mut deal), horizon_ms); } - changes - .iter() - .map(|values| { - (!values.is_empty()) - .then(|| variant_picture(&deal, &defaults, &kind, values, model)) - }) - .collect::>() + variant_picture(&deal, &defaults, &kind, &changes, model) }) .await; let mut corridor: Vec = Vec::new(); - let trades: Vec = pictures + let trades: Vec = Some(picture) .into_iter() - .enumerate() - .filter_map(|(index, picture)| { - let picture = picture?; + .filter_map(|picture| { let outcome = picture.outcome; let fill = outcome.fill?; // Each placement's corridor until the next placement, the last one until the @@ -216,7 +202,7 @@ impl AnalyticsView { .filter(|exit| exit.kind != ExitKind::OpenAtWindowEnd) .map(|exit| (exit.t_ms as f64, exit.price as f32)), is_short, - pattern: variant_pattern(index), + pattern: VARIANT_PATTERN, }) }) .collect(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 47ac5dde..9180d3e3 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -1,6 +1,6 @@ -//! The variant columns and the search of the "Entry/Exit" axis: the edits behind В1/В2, their -//! debounced rescore over the replayable rows, the search that fills В1, and the write of В1 -//! through the shared confirmation dialog. +//! The variant column and the search of the "Entry/Exit" axis: the edits behind В1, their +//! debounced rescore over the replayable rows, the search that fills В1 over the grids the +//! ranges resolve to (`ranges.rs`), and the write of В1 through the shared confirmation dialog. //! //! Every score here is a replay — `variant_tally` over the fit rows whose tape is in memory //! (`TicksData::replayable`) — so the columns describe the SAME subset the "Fact · fit" column @@ -22,7 +22,6 @@ use gpui::*; use rust_i18n::t; use super::super::super::AnalyticsView; -use super::super::shared::N_VAR; use super::state::SuggState; use super::tape::{PendingDeal, prepare_sample}; use crate::analytics::bg::ReadLane; @@ -80,7 +79,7 @@ impl AnalyticsView { .unwrap_or_default() } - /// Arm a debounced rescore of the variant columns — every edit of a cell, every row that + /// Arm a debounced rescore of the variant column — every edit of a cell, every row that /// joins the replayable set, goes through here. pub(in crate::analytics::tuner) fn arm_ticks_variants(&mut self, cx: &mut Context) { if !self.ticks.tape_reading @@ -92,11 +91,11 @@ impl AnalyticsView { } self.latest_reads.cancel(&[ReadLane::TicksVariants]); self.ticks.var_seq = self.ticks.var_seq.wrapping_add(1); - // Nothing to score: an untouched pair of columns costs no clone of the rows and no - // replay — a fetch over hundreds of rows re-arms this once per row. - if (0..N_VAR).all(|i| self.ticks.variant_changes(i).is_empty()) { - self.ticks.var_stats = Default::default(); - self.set_ticks_plan(Default::default()); + // Nothing to score: an untouched column costs no clone of the rows and no replay — a + // fetch over hundreds of rows re-arms this once per row. + if self.ticks.variant_changes().is_empty() { + self.ticks.var_stats = None; + self.set_ticks_plan(HashMap::new()); // The trade pane's modelled trades go with the columns. self.ticks_refresh_model_trades(cx); return; @@ -115,15 +114,14 @@ impl AnalyticsView { })); } - /// Score every touched variant over the replayable rows. + /// Score the touched variant over the replayable rows. fn run_ticks_variants(&mut self, req: u64, cx: &mut Context) { let pending = self.prepared_deals(); let Some(data) = self.ticks.data.data() else { return; }; let kind = data.single_kind().unwrap_or_default().to_string(); - let changes: Vec> = - (0..N_VAR).map(|i| self.ticks.variant_changes(i)).collect(); + let values = self.ticks.variant_changes(); let defaults = self.filter_defaults(cx); let model = super::model_cfg::current(); let n = pending.len(); @@ -133,40 +131,24 @@ impl AnalyticsView { cx, move || { let deals = prepare_sample(pending); - changes - .iter() - .map(|values| { - if values.is_empty() || deals.is_empty() { - return None; - } - let (tally, spent, money) = - variant_tally_by_deal(&deals, &defaults, &kind, values, model); - let plan: HashMap = money - .into_iter() - .filter_map(|(uid, value)| Some((uid, value?))) - .collect(); - Some((stats_of(tally, spent), plan)) - }) - .collect::>() + if values.is_empty() || deals.is_empty() { + return None; + } + let (tally, spent, money) = + variant_tally_by_deal(&deals, &defaults, &kind, &values, model); + let plan: HashMap = money + .into_iter() + .filter_map(|(uid, value)| Some((uid, value?))) + .collect(); + Some((stats_of(tally, spent), plan)) }, - move |this, stats, cx| { + move |this, scored, cx| { if this.ticks.var_seq != req { return; } - let mut plan: [HashMap; N_VAR] = Default::default(); - for ((slot, value), plan) in this - .ticks - .var_stats - .iter_mut() - .zip(stats) - .zip(plan.iter_mut()) - { - *slot = value.map(|(stats, deals)| { - *plan = deals; - stats - }); - } - this.set_ticks_plan(plan); + let (stats, plan) = scored.unzip(); + this.ticks.var_stats = stats; + this.set_ticks_plan(plan.unwrap_or_default()); this.ticks.var_n = n; // The trade pane draws what the columns now count. this.ticks_refresh_model_trades(cx); @@ -175,8 +157,8 @@ impl AnalyticsView { ); } - /// Take the variants' per-deal results; a table sorted by the plan column is re-sorted. - fn set_ticks_plan(&mut self, plan: [HashMap; N_VAR]) { + /// Take the variant's per-deal results; a table sorted by the plan column is re-sorted. + fn set_ticks_plan(&mut self, plan: HashMap) { self.ticks.plan = plan; if self .ticks @@ -188,50 +170,32 @@ impl AnalyticsView { } } - /// One cell of a variant changed: store it and rescore. + /// One cell of the variant changed: store it and rescore. pub(in crate::analytics::tuner) fn set_ticks_variant( &mut self, - index: usize, key: &str, value: String, cx: &mut Context, ) { - self.ticks.set_variant(index, key, value); - self.arm_ticks_variants(cx); - } - - /// Copy one variant column over the other — В1 into В2 keeps a found point while another is - /// tried, В2 into В1 brings a kept one back for Save. - pub(in crate::analytics::tuner) fn ticks_copy_variant( - &mut self, - from: usize, - to: usize, - cx: &mut Context, - ) { - self.ticks.variants[to] = self.ticks.variants[from].clone(); - self.ticks_reset_inputs_of(to); + self.ticks.set_variant(key, value); self.arm_ticks_variants(cx); - cx.notify(); } - /// Clear one variant column. - pub(in crate::analytics::tuner) fn ticks_clear_variant( - &mut self, - index: usize, - cx: &mut Context, - ) { - self.ticks.variants[index].clear(); - self.ticks.var_stats[index] = None; - self.ticks.plan[index].clear(); - self.ticks_reset_inputs_of(index); + /// Clear the variant column. + pub(in crate::analytics::tuner) fn ticks_clear_variant(&mut self, cx: &mut Context) { + self.ticks.variant.clear(); + self.ticks.var_stats = None; + self.ticks.plan.clear(); + self.ticks_reset_variant_inputs(); self.arm_ticks_variants(cx); cx.notify(); } - /// Drop the input boxes of a variant column so they are recreated from the stored values. - fn ticks_reset_inputs_of(&mut self, index: usize) { - let prefix = format!("v{index}:"); - self.ticks.inputs.retain(|id, _| !id.starts_with(&prefix)); + /// Drop the input boxes of the variant column so they are recreated from the stored values. + fn ticks_reset_variant_inputs(&mut self) { + self.ticks + .inputs + .retain(|id, _| !id.starts_with(super::grid::VARIANT_INPUT_PREFIX)); } /// Whether a group may be searched: the kind's support and the share gate. @@ -363,7 +327,7 @@ impl AnalyticsView { } // The other fields as В1 has them: the one field is searched in the variant it // will land in, not in the strategy as it stands. - held.extend(self.ticks.variant_changes(0)); + held.extend(self.ticks.variant_changes()); let locked: HashSet = TICK_PARAMS .iter() .map(|f| f.key) @@ -396,6 +360,9 @@ impl AnalyticsView { .filter(|n| *n > 0); let train_frac = super::super::filter::state::train_frac(self.ticks.train_pct); let keep_corridor = self.ticks.keep_corridor; + // The grids are resolved now, from the ranges as they stand: a range edited while the + // search runs is the next search's. + let (grids, set_aside) = self.ticks_search_grids(); // A floor over the slice the search fits on no point can keep: say so before a run that // can only come back empty. Counted on every replayable row: the search then drops the // deals the strategies as they stand leave open (`closing::closable_at_base`), so a floor @@ -414,12 +381,20 @@ impl AnalyticsView { total: restarts, }; self.ticks.sugg_seq = self.ticks.sugg_seq.wrapping_add(1); - self.ticks.sugg_note = None; + // A typed range the search set aside for the automatic one is said, not swallowed. + self.ticks.sugg_note = (!set_aside.is_empty()).then(|| { + t!( + "analytics.ticks.range_set_aside", + fields = set_aside.join(", ") + ) + .to_string() + }); let seq = self.ticks.sugg_seq; log::info!( target: moon_core::diagnostics::TICKS_AXIS_TARGET, - "[x] ticks search: start #{seq}, {restarts} restart(s) x {max_passes} pass(es) over {} deal(s), entry {vary_entry}, exit {vary_exit}", - pending.len() + "[x] ticks search: start #{seq}, {restarts} restart(s) x {max_passes} pass(es) over {} deal(s), entry {vary_entry}, exit {vary_exit}, {} steps per field, typed ranges set aside {set_aside:?}", + pending.len(), + self.ticks.steps_per_param() ); probe::watch(handle.clone(), restarts, seq); self.poll_ticks_search(handle.clone(), seq, cx); @@ -437,6 +412,7 @@ impl AnalyticsView { vary_entry, vary_exit, locked: &locked, + grids: &grids, restarts, min_n, seed, @@ -464,8 +440,8 @@ impl AnalyticsView { this.ticks.sugg = SuggState::Idle; match result { Ok(result) => { - land_answer(&mut this.ticks.variants[0], only, &result.values); - this.ticks_reset_inputs_of(0); + land_answer(&mut this.ticks.variant, only, &result.values); + this.ticks_reset_variant_inputs(); this.ticks.last_seed = Some(result.seed); this.ticks.last_result = Some(result); this.arm_ticks_variants(cx); @@ -540,7 +516,7 @@ impl AnalyticsView { if targets.is_empty() { return; } - let changes = self.ticks.variant_changes(0); + let changes = self.ticks.variant_changes(); if changes.is_empty() { log::info!("analytics: 'Save' (ticks) - no variant to write"); return; @@ -560,7 +536,7 @@ impl AnalyticsView { let Some(target) = self.selected_targets().into_iter().next() else { return; }; - let changes = self.ticks.variant_changes(0); + let changes = self.ticks.variant_changes(); let mut warns = self.ticks_change_warnings(&changes, cx); warns.extend(self.ticks_unguarded_warning(std::slice::from_ref(&target), &changes, cx)); warns.extend(self.ticks_unmodelled_warns(std::slice::from_ref(&target), cx)); diff --git a/crates/moon-ui-gpui/src/design.rs b/crates/moon-ui-gpui/src/design.rs index aa351920..b24e522e 100644 --- a/crates/moon-ui-gpui/src/design.rs +++ b/crates/moon-ui-gpui/src/design.rs @@ -719,6 +719,26 @@ pub fn dense_input_size(cx: &App) -> MoonInputSize { } } +/// A square icon-only button as tall as a [`dense_input_size`] box — the reset beside the tuner +/// grid's range cells, which must not make its row taller than the cells it sits with. Pass +/// [`dense_glyph_btn_w`] to its `width` for the square. +pub fn dense_glyph_btn_size() -> MoonButtonSize { + let m = MoonSize::Xs.control_metrics(); + MoonButtonSize::Custom { + height: m.line_height, + radius: m.radius, + font_size: m.font_size, + line_height: m.line_height, + gap: m.gap, + } +} + +/// Rendered width of the square [`dense_glyph_btn_size`] button: its own drawn height, for a +/// RENDERED width (`MoonButton::width`), as [`glyph_btn_w`] is. +pub fn dense_glyph_btn_w(cx: &App) -> f32 { + ui_value(cx, MoonSize::Xs.control_metrics().line_height) +} + pub fn ui_value(cx: &App, value: f32) -> f32 { MoonTheme::active_tokens(cx).ui(value) } diff --git a/locales/analytics.yml b/locales/analytics.yml index 0320b13a..10d56e60 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -2132,6 +2132,74 @@ analytics.ticks.cfg_gate_tip: ru: "Какая доля сделок должна воспроизводиться моделью, чтобы группу (вход или выход) можно было подбирать" en: "The share of trades the model must reproduce before a group (entry or exit) may be searched" es: "La cuota de operaciones que el modelo debe reproducir para que un grupo (entrada o salida) se pueda buscar" +analytics.ticks.cfg_steps: + ru: "Шагов на параметр" + en: "Steps per field" + es: "Pasos por campo" +analytics.ticks.cfg_steps_tip: + ru: "На сколько шагов режется автоматический диапазон поля (от %{min} до %{max}); шаг округляется до круглого числа и не мельче точности поля" + en: "How many steps a field's automatic range is cut into (%{min} to %{max}); the step is rounded to a round number and never finer than the field holds" + es: "En cuántos pasos se corta el rango automático de un campo (de %{min} a %{max}); el paso se redondea a un número redondo y nunca es más fino que el campo" +analytics.ticks.range_from: + ru: "от" + en: "from" + es: "desde" +analytics.ticks.range_to: + ru: "до" + en: "to" + es: "hasta" +analytics.ticks.range_step: + ru: "шаг" + en: "step" + es: "paso" +analytics.ticks.range_reset: + ru: "Вернуть диапазон поля на авто" + en: "Put the field's range back to automatic" + es: "Devolver el rango del campo a automático" +analytics.ticks.range_reset_section: + ru: "Вернуть диапазоны раздела на авто" + en: "Put the section's ranges back to automatic" + es: "Devolver los rangos de la sección a automático" +analytics.ticks.range_reset_all: + ru: "Вернуть все диапазоны на авто" + en: "Put every range back to automatic" + es: "Devolver todos los rangos a automático" +analytics.ticks.range_points: + ru: "Значений в переборе: %{n}" + en: "Values to try: %{n}" + es: "Valores a probar: %{n}" +analytics.ticks.range_hint: + ru: "серое — авто по живым стратегиям и выбранным; введённое — ваше, до сброса" + en: "grey is automatic, from the live strategies and the selected ones; typed is yours until the reset" + es: "lo gris es automático, de las estrategias activas y las elegidas; lo escrito es suyo hasta restablecer" +analytics.ticks.range_no_data: + ru: "Нет данных для авто: поле не задано ни у одной стратегии и не имеет значения по умолчанию — задайте диапазон вручную" + en: "No data for an automatic range: no strategy sets the field and it has no default — type the range" + es: "Sin datos para un rango automático: ninguna estrategia fija el campo y no tiene valor por defecto — escriba el rango" +analytics.ticks.range_err_inverted: + ru: "«от» больше «до» — подбор берёт авто" + en: "\"from\" is above \"to\" — the search takes the automatic range" + es: "\"desde\" es mayor que \"hasta\" — la búsqueda toma el rango automático" +analytics.ticks.range_err_step: + ru: "Шаг должен быть больше нуля — подбор берёт авто" + en: "The step must be above zero — the search takes the automatic range" + es: "El paso debe ser mayor que cero — la búsqueda toma el rango automático" +analytics.ticks.range_err_many: + ru: "Больше %{n} значений — подбор берёт авто" + en: "More than %{n} values — the search takes the automatic range" + es: "Más de %{n} valores — la búsqueda toma el rango automático" +analytics.ticks.range_err_edges: + ru: "Только шаг, без «от» и «до», а авто для поля нет — задайте края" + en: "A step alone, with no \"from\" and \"to\", and no automatic range for the field — type the edges" + es: "Solo un paso, sin \"desde\" ni \"hasta\", y el campo no tiene rango automático — escriba los bordes" +analytics.ticks.range_integer: + ru: "поле целое: введённое округляется до целых" + en: "a whole-number field: typed values are rounded to whole ones" + es: "campo entero: lo escrito se redondea a enteros" +analytics.ticks.range_set_aside: + ru: "Диапазон не годится, подбор его не взял: %{fields}" + en: "Range unusable, the search did not take it: %{fields}" + es: "Rango no válido, la búsqueda no lo tomó: %{fields}" analytics.ticks.cfg_entry_section: ru: "Вход варианта" en: "A variant's entry" From 7d6a0e71863179d893792ffdfcacb5ecc38b879d Mon Sep 17 00:00:00 2001 From: guyverino Date: Fri, 25 Sep 2026 10:04:25 +0200 Subject: [PATCH 41/51] fix(tuner): every search runs on from V1 and lands over it "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. --- crates/moon-core/src/db/tuner/ticks/search.rs | 5 +-- .../src/analytics/tuner/ticks/grid.rs | 6 ++-- .../src/analytics/tuner/ticks/state.rs | 3 +- .../src/analytics/tuner/ticks/variants.rs | 35 +++++++++---------- .../analytics/tuner/ticks/variants/tests.rs | 15 ++++---- locales/analytics.yml | 6 ++-- 6 files changed, 36 insertions(+), 34 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index 30cb29c8..2e02b04a 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -111,8 +111,9 @@ pub fn common_horizon_ms(deals: &[PreparedDeal]) -> Option { /// What one search varies and how. pub struct SearchParams<'a> { /// Values held over every deal's own base ([`PreparedDeal::own`]) before the point is laid - /// on — the variant's edits when one field is searched in it (the searched field among - /// them, which the point then overrides); empty otherwise. + /// on — the axis passes the variant's edits for every search, so a search runs on from what + /// the earlier ones found (the searched fields among them, which the point then overrides). + /// Empty searches from the strategies as they stand. pub held: &'a HashMap, /// Schema defaults for the keys a deal's base leaves out. pub defaults: &'a HashMap, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index fe7043d5..251aa5ea 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -150,9 +150,9 @@ impl AnalyticsView { .into_any_element() } - /// Tick or untick every field the grid shows — the header's tick. Unticked is held at its - /// base value by the search, but for a value a switch the variant turns on needs - /// (`search::deps`). + /// Tick or untick every field the grid shows — the header's tick. Unticked is held by the + /// search at В1's value where В1 has one, else at the strategy's, but for a value a switch + /// the variant turns on needs (`search::deps`). fn ticks_set_all(&mut self, fields: &[&'static str], on: bool, cx: &mut Context) { for key in fields { if on { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 767d7ce4..11fa1b06 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -340,7 +340,8 @@ pub(in crate::analytics) struct TicksState { /// The placeholder each range cell's box was last given, by its id in `inputs` — set again /// only when what the search takes there moved (`ranges.rs`). pub(in crate::analytics::tuner) placeholders: HashMap, - /// Unticked rows: held at base by the search, bar what a switch it turns on needs. Persisted. + /// Unticked rows: held by the search at В1's value where В1 has one, else at the strategy's, + /// bar what a switch it turns on needs. Persisted. pub(in crate::analytics::tuner) locked: HashSet, /// The search ranges typed over the automatic ones, by field key; a field absent is fully /// automatic. Persisted. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 9180d3e3..4b6bbcda 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -239,7 +239,8 @@ impl AnalyticsView { ) } - /// "Search all": every ticked field of the groups the gate lets through, into В1. + /// "Search all": every ticked field of the groups the gate lets through, from В1 as it + /// stands and into it ([`land_answer`]). pub(in crate::analytics::tuner) fn ticks_suggest( &mut self, window: &mut Window, @@ -306,9 +307,11 @@ impl AnalyticsView { return self.ticks_search_refused("analytics.ticks.sugg_one_kind", cx); }; let model = super::model_cfg::current(); - // Laid over every deal's own strategy before the search's point: nothing for a search - // of every field, В1's other fields for a search of one. - let mut held: HashMap = HashMap::new(); + // Laid over every deal's own strategy before the search's point: В1 as it stands, + // whatever is searched (the developer, 2026-09-25). A search runs on from what the + // earlier ones found — В1's fields are its base, its unticked ones held at В1's value — + // not from the strategy as if В1 were empty. + let held: HashMap = self.ticks.variant_changes().into_iter().collect(); let (vary_entry, vary_exit, locked) = match only { None => ( self.ticks_group_searchable(ParamGroup::Entry), @@ -325,9 +328,6 @@ impl AnalyticsView { if !self.ticks_group_searchable(field.group) { return self.ticks_search_refused("analytics.ticks.sugg_gated", cx); } - // The other fields as В1 has them: the one field is searched in the variant it - // will land in, not in the strategy as it stands. - held.extend(self.ticks.variant_changes()); let locked: HashSet = TICK_PARAMS .iter() .map(|f| f.key) @@ -440,7 +440,7 @@ impl AnalyticsView { this.ticks.sugg = SuggState::Idle; match result { Ok(result) => { - land_answer(&mut this.ticks.variant, only, &result.values); + land_answer(&mut this.ticks.variant, &result.values); this.ticks_reset_variant_inputs(); this.ticks.last_seed = Some(result.seed); this.ticks.last_result = Some(result); @@ -641,21 +641,18 @@ impl AnalyticsView { } } -/// Lay a search's answer into В1. A search of every field replaces В1 with it. A search of one -/// field lays its cells over В1's: the searched field, and every value the answer completed for a -/// switch it turned on (`search::deps` — `UseTakeProfit` brings its `TakeProfit`), which the -/// search scored and Save must write with it; the search locked every other field, so nothing -/// else moves, and a field it left at its base keeps what В1 had. +/// Lay a search's answer over В1 — a search of one field or of every one alike. Every search runs +/// from В1 as it stands (`ticks_start_search`), and the answer holds what it moved off that: the +/// searched fields, and every value it completed for a switch it turned on (`search::deps` — +/// `UseTakeProfit` brings its `TakeProfit`), which the search scored and Save must write with +/// it. A field it left where В1 had it is not in the answer and keeps В1's cell. В1 is never +/// emptied here: only the user clears it (the developer, 2026-09-25). /// /// Args: /// v1: В1's cells. -/// only: The searched field, for a search of one. /// values: The answer ([`SearchResult::values`](moon_core::db::tuner::ticks::search::SearchResult)). -fn land_answer(v1: &mut HashMap, only: Option<&str>, values: &[(String, String)]) { - match only { - None => *v1 = values.iter().cloned().collect(), - Some(_) => v1.extend(values.iter().cloned()), - } +fn land_answer(v1: &mut HashMap, values: &[(String, String)]) { + v1.extend(values.iter().cloned()); } /// The status band's account of the last search: restarts, the winning one, its passes and diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs index 4d689b97..4f338890 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs @@ -26,7 +26,6 @@ fn a_search_of_one_field_lands_the_values_it_completed() { let mut v1 = cells(&[("SellPrice", "1.5"), ("StopLoss", "-3")]); land_answer( &mut v1, - Some("UseTakeProfit"), &answer(&[("TakeProfit", "1"), ("UseTakeProfit", "YES")]), ); assert_eq!( @@ -44,14 +43,18 @@ fn a_search_of_one_field_lands_the_values_it_completed() { #[test] fn a_field_left_at_its_base_keeps_what_v1_had() { let mut v1 = cells(&[("SellPrice", "1.5")]); - land_answer(&mut v1, Some("SellPrice"), &[]); + land_answer(&mut v1, &[]); assert_eq!(v1, cells(&[("SellPrice", "1.5")])); } -/// "Search all" replaces В1 with its answer. +/// "Search all" lays its answer over В1 and never empties it (the developer, 2026-09-25): a +/// search runs from В1 as it stands, so a cell it did not move is still В1's, and В1 is cleared +/// only by the user. #[test] -fn a_search_of_every_field_replaces_v1() { +fn a_search_of_every_field_keeps_what_v1_had() { let mut v1 = cells(&[("SellPrice", "1.5"), ("StopLoss", "-3")]); - land_answer(&mut v1, None, &answer(&[("SellPrice", "2")])); - assert_eq!(v1, cells(&[("SellPrice", "2")])); + land_answer(&mut v1, &answer(&[("SellPrice", "2")])); + assert_eq!(v1, cells(&[("SellPrice", "2"), ("StopLoss", "-3")])); + land_answer(&mut v1, &[]); + assert_eq!(v1, cells(&[("SellPrice", "2"), ("StopLoss", "-3")])); } diff --git a/locales/analytics.yml b/locales/analytics.yml index 10d56e60..d7d1c914 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -2101,9 +2101,9 @@ analytics.ticks.suggest_one_tip: en: "Search the selected field %{field} alone, ticked or not; the other fields stay as V1 has them. The answer goes into V1's %{field} cell — and into the fields a switch it turns on needs (UseTakeProfit → TakeProfit)" es: "Buscar solo el campo seleccionado %{field}, marcado o no; los demás campos quedan como en V1. La respuesta va a la celda %{field} de V1 — y a los campos que requiere un interruptor que active (UseTakeProfit → TakeProfit)" analytics.ticks.suggest_all_tip: - ru: "Подобрать все поля с галочкой в группах, прошедших порог воспроизводимости, — сейчас их %{n}. Найденная точка заменяет весь столбец В1; поле без галочки остаётся как есть, кроме значения, которое требует включённый подбором переключатель (UseTakeProfit → TakeProfit): оно одно на все стратегии" - en: "Search every ticked field of the groups past the reproduction gate — %{n} now. The point found replaces the whole V1 column; an unticked field stays as it is, but for a value a switch the search turns on needs (UseTakeProfit → TakeProfit): one value for every strategy" - es: "Buscar todos los campos marcados de los grupos que pasan el umbral de reproducción — ahora %{n}. El punto hallado reemplaza toda la columna V1; un campo sin marcar queda como está, salvo el valor que requiere un interruptor que la búsqueda active (UseTakeProfit → TakeProfit): un valor para todas las estrategias" + ru: "Подобрать все поля с галочкой в группах, прошедших порог воспроизводимости, — сейчас их %{n}. Подбор идёт от того, что уже стоит в В1, и пишет найденное поверх В1 — остальные ячейки В1 не трогает (очистить В1 — только ✕). Поле без галочки держится как в В1, кроме значения, которое требует включённый подбором переключатель (UseTakeProfit → TakeProfit): оно одно на все стратегии" + en: "Search every ticked field of the groups past the reproduction gate — %{n} now. The search starts from what V1 already holds and writes what it finds over V1, leaving V1's other cells alone (only ✕ clears V1). An unticked field stays as V1 has it, but for a value a switch the search turns on needs (UseTakeProfit → TakeProfit): one value for every strategy" + es: "Buscar todos los campos marcados de los grupos que pasan el umbral de reproducción — ahora %{n}. La búsqueda parte de lo que ya tiene V1 y escribe lo hallado sobre V1, sin tocar sus demás celdas (solo ✕ vacía V1). Un campo sin marcar queda como en V1, salvo el valor que requiere un interruptor que la búsqueda active (UseTakeProfit → TakeProfit): un valor para todas las estrategias" analytics.ticks.suggest_one_none: ru: "Выберите поле: щёлкните по его имени в таблице" en: "Pick a field: click its name in the grid" From a1be7171966203f7fb3a851c34df0e770e6ee140 Mon Sep 17 00:00:00 2001 From: guyverino Date: Fri, 25 Sep 2026 12:45:36 +0200 Subject: [PATCH 42/51] feat(tuner): nest the exit search under every entry point, estimate the 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. --- .../src/db/tuner/threshold_search/handle.rs | 14 + crates/moon-core/src/db/tuner/ticks/search.rs | 114 ++++- .../src/db/tuner/ticks/search/nested.rs | 188 ++++++++ .../src/db/tuner/ticks/search/nested/tests.rs | 121 +++++ .../src/db/tuner/ticks/search/size.rs | 136 ++++++ .../src/db/tuner/ticks/search/size/tests.rs | 69 +++ .../src/db/tuner/ticks/search/tests.rs | 16 +- .../db/tuner/ticks/tests/real_data/search.rs | 244 +++++++++- .../src/analytics/tuner/ticks/cfg.rs | 19 +- .../src/analytics/tuner/ticks/estimate.rs | 429 ++++++++++++++++++ .../analytics/tuner/ticks/estimate/tests.rs | 23 + .../src/analytics/tuner/ticks/grid.rs | 5 + .../src/analytics/tuner/ticks/mod.rs | 1 + .../src/analytics/tuner/ticks/rows/tests.rs | 1 + .../src/analytics/tuner/ticks/state.rs | 12 + .../src/analytics/tuner/ticks/unmodelled.rs | 9 + .../src/analytics/tuner/ticks/variants.rs | 129 +++--- .../analytics/tuner/ticks/variants/tests.rs | 39 +- locales/analytics.yml | 60 ++- 19 files changed, 1510 insertions(+), 119 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/search/nested.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/nested/tests.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/size.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/search/size/tests.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate/tests.rs diff --git a/crates/moon-core/src/db/tuner/threshold_search/handle.rs b/crates/moon-core/src/db/tuner/threshold_search/handle.rs index a16bffb9..ab06a959 100644 --- a/crates/moon-core/src/db/tuner/threshold_search/handle.rs +++ b/crates/moon-core/src/db/tuner/threshold_search/handle.rs @@ -31,6 +31,8 @@ struct Signals { abandoned: AtomicBool, /// Packed `(step, done, total)` of a multi-stage run. See [`SearchHandle::stage`]. stage: AtomicU64, + /// Outer points scored by a whole inner search. See [`SearchHandle::points`]. + points: AtomicUsize, } /// Bits reserved for `done` and `total` in the packed stage word; `step` takes what is left. @@ -95,6 +97,18 @@ impl SearchHandle { self.0.completed.fetch_add(1, Ordering::Relaxed); } + /// Points of an outer search each scored by a whole inner one — the Entry/Exit axis' entry + /// points, each with the exit searched under it — so a run whose restarts take minutes + /// still shows it is moving. Zero for a search with no inner one. + pub fn points(&self) -> usize { + self.0.points.load(Ordering::Relaxed) + } + + /// Record one outer point scored by a whole inner search. + pub(crate) fn record_point(&self) { + self.0.points.fetch_add(1, Ordering::Relaxed); + } + /// Whether any unit of work was dropped unfinished, i.e. the answer is less than was asked /// for. /// diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index 2e02b04a..4ba77f1a 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -4,7 +4,9 @@ //! strategy itself; the others from the strategy moved a few steps on a few fields, each walking //! the fields in an order of its own. A pass that moves no single field then tries PAIRS of the //! Entry group's number fields, one a step down and another a step up, so a corridor's distance -//! can move between the base fields and the modifiers ([`descend`]). +//! can move between the base fields and the modifiers ([`descend`]). A search of both groups +//! scores every entry point by a descent of the exit under it ([`nested`]), so an entry that pays +//! only with its own exit is reached. //! //! A point is a set of strategy values in the strategy's own spelling, laid over the values //! each deal's OWN strategy holds now ([`PreparedDeal::own`]) — a field the point leaves alone @@ -111,8 +113,9 @@ pub fn common_horizon_ms(deals: &[PreparedDeal]) -> Option { /// What one search varies and how. pub struct SearchParams<'a> { /// Values held over every deal's own base ([`PreparedDeal::own`]) before the point is laid - /// on — the axis passes the variant's edits for every search, so a search runs on from what - /// the earlier ones found (the searched fields among them, which the point then overrides). + /// on — the axis passes the variant's edits for every search, so the fields it leaves alone + /// run at what the earlier searches found. The fields it varies are not held: their values + /// here are set aside, and each starts from the strategies ([`SearchResult::searched`]). /// Empty searches from the strategies as they stand. pub held: &'a HashMap, /// Schema defaults for the keys a deal's base leaves out. @@ -187,6 +190,9 @@ pub struct SearchStats { /// Deals the strategies as they stand leave open inside the tape, taken out of the sample /// before the search (`closing::closable_at_base`). pub left_open: usize, + /// Entry points scored by a whole search of the exit under them — a search of both groups + /// ([`nested`]); zero for a search of one. + pub entry_points: usize, } /// What the search found. @@ -195,6 +201,10 @@ pub struct SearchResult { /// The winning values, in strategy spelling — only the fields that moved off the base of /// at least one deal. pub values: Vec<(String, String)>, + /// The fields the search varied, sorted: each one's answer is in `values`, or it is at the + /// strategies' own value — never at what the held edits had it at, which the search set + /// aside. + pub searched: Vec, /// What they achieve on the deals they were fitted on. pub train: Tally, /// What they achieve on the deals held back, when any were. @@ -738,6 +748,19 @@ pub fn suggest( handle: &SearchHandle, ) -> Result { let fields = varied(params); + // A searched field starts from the strategies, never from what the held edits put there + // (LinKvo, 2026-09-25: "a ticked field is searched anew, whatever В1 holds"): its held value + // is set aside, and only the fields the search leaves alone are held. + let held: HashMap = params + .held + .iter() + .filter(|(key, _)| !fields.iter().any(|f| f.key == key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + let params = &SearchParams { + held: &held, + ..*params + }; // The strategies of the WHOLE sample stay the bases throughout, a strategy whose every deal // leaves the sample below among them: where each number field starts, what completes a point // (`deps`) and the parameters built per base are then the same for the filter and for every @@ -802,18 +825,24 @@ pub fn suggest( bases.params(params.held, params.defaults, &full, params.kind, model) }; let coupling = coupled::Coupling::of(&fields, &per_base_at); - let evaluate = |point: &Point| -> Option { - let per_base = per_base_at(point); - // A point that inverts the corridor's two fields is never proposed, whatever the - // switch: the searched fields are gridded one by one, and nothing else ties them. Only - // a search of the Entry group can produce one — an Exit search leaves the strategy's - // own fields alone, whatever they hold. - if (params.vary_entry && inverts(&start_ordered, &per_base)) + // A point that inverts the corridor's two fields is never proposed, whatever the switch: the + // searched fields are gridded one by one, and nothing else ties them. Only a search of the + // Entry group can produce one — an Exit search leaves the strategy's own fields alone, + // whatever they hold. Both rules read the entry alone, which is what lets the nested search + // refuse an entry point before it searches the exit under it. + let corridor_refuses = |per_base: &[(EntryParams, ExitParams)]| { + let refused = (params.vary_entry && inverts(&start_ordered, per_base)) || guard .as_ref() - .is_some_and(|g| !g.holds(&bases.of_deal, &per_base)) - { + .is_some_and(|g| !g.holds(&bases.of_deal, per_base)); + if refused { cornered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + refused + }; + let evaluate = |point: &Point| -> Option { + let per_base = per_base_at(point); + if corridor_refuses(&per_base) { return None; } // Counted here, past the refusals: what the stats call a scored point is a replay. @@ -839,6 +868,15 @@ pub fn suggest( } else { Vec::new() }; + // Both groups searched: every entry point is scored by a search of the exit under it + // (`nested`), each group with its own coupling — the Delta Modifiers diagonals are the exit's. + let (entry_fields, exit_fields): (Vec<&'static TickParam>, Vec<&'static TickParam>) = + fields.iter().partition(|f| f.group == ParamGroup::Entry); + let nested_on = !entry_fields.is_empty() && !exit_fields.is_empty(); + let entry_coupling = coupled::Coupling::of(&entry_fields, &per_base_at); + let exit_coupling = coupled::Coupling::of(&exit_fields, &per_base_at); + let entry_scored = std::sync::atomic::AtomicUsize::new(0); + let refuses_entry = |entry: &Point| corridor_refuses(&per_base_at(entry)); let runs: Vec = install(|| { (0..restarts) .into_par_iter() @@ -859,18 +897,39 @@ pub fn suggest( shuffle(&mut order, &mut state); perturb(&mut point, params.grids, &order, &start, &mut state); } - let walked = descend( - point, - params.grids, - &order, - &pairs, - &coupling, - &start, - &evaluate, - min_n, - max_passes, - handle, - )?; + let walked = if nested_on { + // Each group in this restart's own order. + let (entry, exit): (Vec<&'static TickParam>, Vec<&'static TickParam>) = + order.iter().partition(|f| f.group == ParamGroup::Entry); + let walk = nested::Nested { + grids: params.grids, + start: &start, + entry: &entry, + exit: &exit, + pairs: &pairs, + entry_coupling: &entry_coupling, + exit_coupling: &exit_coupling, + min_n, + max_passes, + handle, + refused: &refuses_entry, + searched: &entry_scored, + }; + nested::descend_nested(point, &walk, &evaluate) + } else { + descend( + point, + params.grids, + &order, + &pairs, + &coupling, + &start, + &evaluate, + min_n, + max_passes, + handle, + ) + }?; handle.record_restart(); Some(Run { restart, @@ -921,6 +980,7 @@ pub fn suggest( evaluations: evaluations.load(std::sync::atomic::Ordering::Relaxed), refused, left_open: left_open.len(), + entry_points: entry_scored.load(std::sync::atomic::Ordering::Relaxed), }; let (point, score) = (best.point, best.score); // The strategies as they stand against the answer, on the slice both were fitted on: a best @@ -999,8 +1059,11 @@ pub fn suggest( } else { (None, 0) }; + let mut searched: Vec = fields.iter().map(|f| f.key.to_string()).collect(); + searched.sort(); Ok(SearchResult { values, + searched, train: train_tally, holdout, holdout_open, @@ -1166,7 +1229,10 @@ mod closing; pub use self::closing::unguarded_strategies; mod coupled; mod deps; +mod nested; +mod size; pub(in crate::db::tuner::ticks) use self::deps::strategy_values; +pub use self::size::{SearchSize, point_cost, search_size}; #[cfg(test)] pub(in crate::db::tuner::ticks) mod test_grids; diff --git a/crates/moon-core/src/db/tuner/ticks/search/nested.rs b/crates/moon-core/src/db/tuner/ticks/search/nested.rs new file mode 100644 index 00000000..7aef6038 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/nested.rs @@ -0,0 +1,188 @@ +//! "Search all" over the Entry and the Exit group at once (LinKvo, 2026-09-25): every entry point +//! the descent visits is scored by a whole descent of the exit under it, so a point is judged with +//! the exit that suits it rather than with the one that suited the point before. +//! +//! One descent over both groups turns one field at a time, and a move of the entry is then judged +//! under the exit tuned for the OLD entry: a better entry that needs its own exit reads as worse, +//! is refused, and the exit settles around the entry it started from (the bench of 2026-09-25, +//! `ENTRY_EXIT_TUNER.md` §11). Nesting the exit under each entry point is what reaches it. +//! +//! The price is the product: every entry point costs a whole exit descent. Two things keep it +//! down without changing what a point scores. The exit descent of an entry point starts from the +//! exit found under the best entry point so far — the neighbour's exit is the nearest start there +//! is, and a start near the answer converges in a pass or two. And an entry point is scored once +//! per restart: the exit found under it is kept ([`ExitCache`]). Not across restarts: each walks +//! from its own start, and an exit found from another restart's start would cap an entry point at +//! what that start could reach. An entry point the corridor rules refuse is refused before its exit +//! is searched: those rules read the entry alone. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use super::{Point, Walked, better_score, coupled, descend}; +use crate::db::metrics::Tally; +use crate::db::tuner::threshold_search::SearchHandle; +use crate::db::tuner::ticks::params::TickParam; +use crate::db::tuner::ticks::params::range::Grids; + +/// An entry point in one spelling whatever order its fields were set in. +type EntryKey = Vec<(&'static str, String)>; + +fn key_of(point: &Point) -> EntryKey { + let mut key: EntryKey = point.iter().map(|(k, v)| (*k, v.clone())).collect(); + key.sort(); + key +} + +/// The exit found under each entry point one restart scored, and what the two scored together. +#[derive(Default)] +struct ExitCache { + found: Mutex, Point)>>, +} + +impl ExitCache { + fn get(&self, key: &EntryKey) -> Option<(Option, Point)> { + self.found + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(key) + .cloned() + } + + fn put(&self, key: EntryKey, found: (Option, Point)) { + self.found + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(key, found); + } +} + +/// What one nested descent walks and how: the two groups' fields in the order this restart visits +/// them, the moves each group makes, and what refuses an entry point outright. +pub(super) struct Nested<'a, 'c> { + pub grids: &'a Grids, + /// Where each number field starts on its grid (`deps::dependents_of`). + pub start: &'a HashMap<&'static str, usize>, + /// The Entry group's fields, the outer descent's. + pub entry: &'a [&'static TickParam], + /// The Exit group's fields, the inner descent's. + pub exit: &'a [&'static TickParam], + /// The Entry fields that move in pairs (`descend`). + pub pairs: &'a [&'static TickParam], + /// The coupling of the entry fields — none of them is in the Delta Modifiers section, so it + /// couples nothing; it keeps the exit's diagonals out of the outer descent. + pub entry_coupling: &'a coupled::Coupling<'c>, + /// The coupling of the exit fields, the Delta Modifiers section's. + pub exit_coupling: &'a coupled::Coupling<'c>, + pub min_n: i64, + pub max_passes: usize, + pub handle: &'a SearchHandle, + /// Whether an entry point is out before its exit is searched — the corridor rules, which + /// read the entry alone; the search counts the refusal there. + pub refused: &'a (dyn Fn(&Point) -> bool + Sync), + /// Entry points scored by an exit descent of their own, over every restart. + pub searched: &'a AtomicUsize, +} + +/// The descent of one restart over both groups, from `point`: the outer descent walks the entry +/// fields, and scores each entry point by an inner descent of the exit fields under it. +/// +/// Args: +/// point: The restart's start — its entry fields start the outer descent, its exit fields +/// the first inner one. +/// nested: What to walk and how. +/// evaluate: The score of a whole point, entry and exit. +/// +/// Returns: +/// Where it stopped — the entry and the exit found under it — or `None` when the run was +/// stopped. +pub(super) fn descend_nested( + point: Point, + nested: &Nested<'_, '_>, + evaluate: &(dyn Fn(&Point) -> Option + Sync), +) -> Option { + let is_exit = |key: &str| nested.exit.iter().any(|f| f.key == key); + let (exit, entry): (Point, Point) = point.into_iter().partition(|(key, _)| is_exit(key)); + // The exit found under the best entry point so far, with that point's score: where the next + // entry point's exit descent starts. The outer descent keeps a point only when it beats the + // score, so the best so far is the point it stands on. + let warm: Mutex<(Option, Point)> = Mutex::new((None, exit)); + let cache = ExitCache::default(); + // A point better than the best so far hands its exit on. + let keep_if_best = |score: &Option, found: &Point| { + let mut best = warm.lock().unwrap_or_else(PoisonError::into_inner); + if better_score(score, &best.0, nested.min_n) { + *best = (score.clone(), found.clone()); + } + }; + let score_entry = |entry: &Point| -> Option { + if (nested.refused)(entry) { + return None; + } + let key = key_of(entry); + // Scored before in this restart, and offered as the best then. + if let Some((score, _)) = cache.get(&key) { + return score; + } + let mut start = warm + .lock() + .unwrap_or_else(PoisonError::into_inner) + .1 + .clone(); + start.extend(entry.iter().map(|(k, v)| (*k, v.clone()))); + // A stop inside reads here as a refused point; the outer descent notices the stop at its + // next field and answers nothing. + let walked = descend( + start, + nested.grids, + nested.exit, + &[], + nested.exit_coupling, + nested.start, + evaluate, + nested.min_n, + nested.max_passes, + nested.handle, + )?; + nested.searched.fetch_add(1, Ordering::Relaxed); + nested.handle.record_point(); + let found: Point = walked + .point + .into_iter() + .filter(|(key, _)| is_exit(key)) + .collect(); + keep_if_best(&walked.score, &found); + cache.put(key, (walked.score.clone(), found)); + walked.score + }; + let walked = descend( + entry, + nested.grids, + nested.entry, + nested.pairs, + nested.entry_coupling, + nested.start, + &score_entry, + nested.min_n, + nested.max_passes, + nested.handle, + )?; + // The exit scored with the entry the descent stopped on; a refused one never ran its own, and + // keeps the last exit found. + let exit = cache + .get(&key_of(&walked.point)) + .map(|(_, exit)| exit) + .unwrap_or_else(|| { + warm.lock() + .unwrap_or_else(PoisonError::into_inner) + .1 + .clone() + }); + let mut point = walked.point; + point.extend(exit); + Some(Walked { point, ..walked }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/search/nested/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/nested/tests.rs new file mode 100644 index 00000000..d845760f --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/nested/tests.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; + +use super::super::test_grids::legacy; +use super::super::{DEFAULT_MAX_PASSES, grid_index}; +use super::*; +use crate::db::tuner::ticks::TICK_PARAMS; + +fn field(key: &str) -> &'static TickParam { + TICK_PARAMS.iter().find(|f| f.key == key).expect("a knob") +} + +/// A score over one entry field and one exit field, by their grid steps: the start (10, 5) scores +/// 5; the exit alone a step up, (10, 6), scores 6; the entry a step up under that exit, (11, 6), +/// scores 4 — worse — and only with its own exit, (11, 7), does it score 10. Anything else scores 1. +fn objective<'a>( + entry: &'static TickParam, + exit: &'static TickParam, + start: &'a HashMap<&'static str, usize>, +) -> impl Fn(&Point) -> Option + Sync + 'a { + move |point: &Point| { + let at = ( + grid_index(legacy(), entry, point, start).expect("entry"), + grid_index(legacy(), exit, point, start).expect("exit"), + ); + let mut tally = Tally::default(); + tally.push(match at { + (10, 5) => 5.0, + (10, 6) => 6.0, + (11, 6) => 4.0, + (11, 7) => 10.0, + _ => 1.0, + }); + Some(tally) + } +} + +/// The entry that only pays with its own exit: a descent of both groups settles on the exit's +/// own step (6) — the entry move is judged under the exit tuned for the old entry and reads as +/// worse — where the nested one, searching the exit under every entry point, reaches 10. +#[test] +fn the_nested_descent_reaches_an_entry_that_pays_only_with_its_own_exit() { + let entry = field("MShotPrice"); + let exit = field("SellPrice"); + let start: HashMap<&'static str, usize> = [(entry.key, 10), (exit.key, 5)].into(); + let evaluate = objective(entry, exit, &start); + let handle = SearchHandle::new(); + let none = coupled::Coupling::none(); + let searched = std::sync::atomic::AtomicUsize::new(0); + let nested = Nested { + grids: legacy(), + start: &start, + entry: &[entry], + exit: &[exit], + pairs: &[], + entry_coupling: &none, + exit_coupling: &none, + min_n: 1, + max_passes: DEFAULT_MAX_PASSES, + handle: &handle, + refused: &|_: &Point| false, + searched: &searched, + }; + let walked = descend_nested(Point::new(), &nested, &evaluate).expect("not stopped"); + let at = ( + grid_index(legacy(), entry, &walked.point, &start), + grid_index(legacy(), exit, &walked.point, &start), + ); + assert_eq!(at, (Some(11), Some(7)), "{:?}", walked.point); + assert!((walked.score.expect("scored").profit - 10.0).abs() < 1e-9); + // Every entry point the outer descent scored ran an exit search, and said so — once each: a + // point scored again in the restart is the one already found. + let searched = searched.load(std::sync::atomic::Ordering::Relaxed); + assert!(searched > 1, "{searched}"); + assert_eq!(handle.points(), searched); + assert!( + searched < legacy().arity(entry) * 3, + "each entry value once per pass at most: {searched}" + ); + // The flat descent over both groups stops on the exit's own step: the defect this fixes. + let flat = descend( + Point::new(), + legacy(), + &[entry, exit], + &[], + &none, + &start, + &evaluate, + 1, + DEFAULT_MAX_PASSES, + &SearchHandle::new(), + ) + .expect("not stopped"); + assert!((flat.score.expect("scored").profit - 6.0).abs() < 1e-9); +} + +/// A stop inside the inner search stops the whole descent: nothing is answered. +#[test] +fn a_stopped_nested_descent_answers_nothing() { + let entry = field("MShotPrice"); + let exit = field("SellPrice"); + let start: HashMap<&'static str, usize> = [(entry.key, 10), (exit.key, 5)].into(); + let evaluate = objective(entry, exit, &start); + let handle = SearchHandle::new(); + handle.cancel(); + let none = coupled::Coupling::none(); + let nested = Nested { + grids: legacy(), + start: &start, + entry: &[entry], + exit: &[exit], + pairs: &[], + entry_coupling: &none, + exit_coupling: &none, + min_n: 1, + max_passes: DEFAULT_MAX_PASSES, + handle: &handle, + refused: &|_: &Point| false, + searched: &std::sync::atomic::AtomicUsize::new(0), + }; + assert!(descend_nested(Point::new(), &nested, &evaluate).is_none()); +} diff --git a/crates/moon-core/src/db/tuner/ticks/search/size.rs b/crates/moon-core/src/db/tuner/ticks/search/size.rs new file mode 100644 index 00000000..e1df43b4 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/size.rs @@ -0,0 +1,136 @@ +//! How much a search will score, known before it runs — what the axis shows under its grid and +//! what asks before a long run (LinKvo, 2026-09-25: "how many variants, and roughly how long"). +//! +//! The count is the descent's own arithmetic: one pass tries every other value of each field it +//! varies, a pass that moves nothing then tries the Entry pairs, and a search of both groups +//! runs a whole exit descent per entry point it scores ([`super::nested`]). What the count cannot +//! know is how many passes a descent takes before one changes nothing; it takes the typical +//! number the bench measured ([`PASSES`], [`INNER_PASSES`]). The time is the count by what one +//! point costs on this sample ([`point_cost`]), measured, never assumed. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use rayon::prelude::*; + +use super::{PreparedDeal, SearchParams, train_len, variant_tally, varied}; +use crate::db::tuner::threshold_search::search::install; +use crate::db::tuner::ticks::params::{ParamGroup, ParamKind}; +use crate::db::tuner::ticks::settings::ModelSettings; + +/// Passes one descent is counted at: the pass that moves, the one that moves what the first made +/// worth moving, and the one that finds nothing left. +const PASSES: f64 = 3.0; + +/// Passes the exit descent under one entry point is counted at: it starts from the exit found +/// under the best entry point so far, and one pass that moves and one that does not usually end +/// it. +const INNER_PASSES: f64 = 2.0; + +/// How much one search scores. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SearchSize { + /// Points scored, each a replay of the training slice. + pub points: f64, + /// Entry points each scored by a whole search of the exit under them; zero for a search of + /// one group. + pub entry_points: f64, + /// Fields of the Entry group the search varies. + pub entry_fields: usize, + /// Fields of the Exit group the search varies. + pub exit_fields: usize, +} + +impl SearchSize { + /// Whether the search nests the exit under every entry point. + pub fn nested(&self) -> bool { + self.entry_points > 0.0 + } + + /// Roughly how long it runs, at `per_point` a scored point. + pub fn time(&self, per_point: Duration) -> Duration { + Duration::from_secs_f64((self.points * per_point.as_secs_f64()).min(u32::MAX as f64)) + } +} + +/// The size of the search `params` describe; the held values, the defaults, the seed and the +/// sample play no part in it. +pub fn search_size(params: &SearchParams<'_>) -> SearchSize { + let fields = varied(params); + let of = |group: ParamGroup| fields.iter().filter(move |f| f.group == group); + // One pass tries every value of a field but the one it stands on. + let span = |group: ParamGroup| -> f64 { + of(group) + .map(|f| params.grids.arity(f).saturating_sub(1) as f64) + .sum() + }; + let numbers = of(ParamGroup::Entry) + .filter(|f| f.kind == ParamKind::Num) + .count() as f64; + // Every ordered pair of two Entry number fields, once, in the pass that moves nothing. + let pairs = numbers * (numbers - 1.0).max(0.0); + let restarts = params.restarts.max(1) as f64; + let cap = params.max_passes.max(1) as f64; + let passes = PASSES.min(cap); + let (entry_fields, exit_fields) = (of(ParamGroup::Entry).count(), of(ParamGroup::Exit).count()); + let both = entry_fields > 0 && exit_fields > 0; + if both { + let entry_points = restarts * (passes * span(ParamGroup::Entry) + pairs); + let per_entry = INNER_PASSES.min(cap) * span(ParamGroup::Exit); + SearchSize { + points: entry_points * per_entry, + entry_points, + entry_fields, + exit_fields, + } + } else { + SearchSize { + points: restarts + * (passes * (span(ParamGroup::Entry) + span(ParamGroup::Exit)) + pairs), + entry_points: 0.0, + entry_fields, + exit_fields, + } + } +} + +/// What one scored point costs on this sample: the strategies as they stand replayed over the +/// training slice, `parallel` replays side by side on the search's own pool — the way a search +/// runs its restarts — so the figure is the same quantity a finished search's time over its +/// scored points is, and the two can stand in for each other. +/// +/// Args: +/// deals: The sample, chronological, cut at its horizon (`clip_to_horizon`). +/// defaults, kind, model: As for [`super::variant_tally`]. +/// train_frac: [`SearchParams::train_frac`]. +/// parallel: Replays run side by side — the search's restarts; at least one. +/// +/// Returns: +/// The time of one replay of the training slice, amortized over the replays run side by +/// side; zero for an empty sample. +pub fn point_cost( + deals: &[PreparedDeal], + defaults: &HashMap, + kind: &str, + model: ModelSettings, + train_frac: f64, + parallel: usize, +) -> Duration { + // More side by side than the pool has threads adds nothing but waiting. + let runs = parallel.clamp(1, 64); + let closes: Vec = deals.iter().map(|d| d.deal.close_ms).collect(); + let train = &deals[..train_len(&closes, train_frac)]; + if train.is_empty() { + return Duration::ZERO; + } + let started = Instant::now(); + install(|| { + (0..runs).into_par_iter().for_each(|_| { + let _ = variant_tally(train, defaults, kind, &[], model); + }) + }); + started.elapsed() / runs as u32 +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/search/size/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/size/tests.rs new file mode 100644 index 00000000..7633dc66 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/size/tests.rs @@ -0,0 +1,69 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use super::super::test_grids::legacy; +use super::super::{DEFAULT_MAX_PASSES, SearchParams}; +use super::*; +use crate::db::tuner::ticks::TICK_PARAMS; + +/// A MoonShot search with every field locked but `free`. +fn size_of(free: &[&str], restarts: usize) -> SearchSize { + let held = HashMap::new(); + let defaults = HashMap::new(); + let locked: HashSet = TICK_PARAMS + .iter() + .map(|f| f.key.to_string()) + .filter(|k| !free.contains(&k.as_str())) + .collect(); + search_size(&SearchParams { + held: &held, + defaults: &defaults, + kind: "MoonShot", + vary_entry: true, + vary_exit: true, + locked: &locked, + grids: legacy(), + restarts, + min_n: None, + seed: Some(1), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, + model: ModelSettings::default(), + }) +} + +fn span(key: &str) -> f64 { + let field = TICK_PARAMS.iter().find(|f| f.key == key).expect("a knob"); + (legacy().arity(field) - 1) as f64 +} + +#[test] +fn one_group_counts_its_passes_and_both_count_an_exit_search_per_entry_point() { + // The exit alone: three passes over the take's grid, per restart. + let exit = size_of(&["SellPrice"], 2); + assert!(!exit.nested()); + assert_eq!(exit.points, 2.0 * 3.0 * span("SellPrice")); + // The entry alone: its passes, plus the pairs — none with one number field. + let entry = size_of(&["MShotPrice"], 2); + assert_eq!(entry.points, 2.0 * 3.0 * span("MShotPrice")); + // Two Entry number fields: two ordered pairs on top. + let two = size_of(&["MShotPrice", "MShotPriceMin"], 1); + assert_eq!( + two.points, + 3.0 * (span("MShotPrice") + span("MShotPriceMin")) + 2.0 + ); + // Both groups: every entry point runs two passes of the exit. + let both = size_of(&["MShotPrice", "SellPrice"], 2); + assert!(both.nested()); + assert_eq!((both.entry_fields, both.exit_fields), (1, 1)); + assert_eq!((entry.entry_fields, entry.exit_fields), (1, 0)); + assert_eq!(both.entry_points, 2.0 * 3.0 * span("MShotPrice")); + assert_eq!(both.points, both.entry_points * 2.0 * span("SellPrice")); + // A thousand points at a millisecond each is a second. + let size = SearchSize { + points: 1000.0, + ..SearchSize::default() + }; + assert_eq!(size.time(Duration::from_millis(1)), Duration::from_secs(1)); +} diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index e361c41b..c1296d00 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -456,14 +456,22 @@ fn a_search_holds_each_deals_own_value_and_reports_a_value_one_strategy_lacks() "{}", result.train.profit ); - // Held over every base, the same take is no change at all. - let held: HashMap = [("SellPrice".to_string(), "1".to_string())].into(); + assert_eq!(result.searched, vec!["SellPrice".to_string()]); + // A held value of the searched field is set aside (LinKvo, 2026-09-25): the search starts + // from the strategies, and the answer is reported against them — the same take again, not + // "no change" because the held edits already had it. + let held: HashMap = [("SellPrice".to_string(), "0.4".to_string())].into(); let params = SearchParams { held: &held, ..params }; - let result = suggest(&deals, ¶ms, &SearchHandle::new()).expect("a result"); - assert!(result.values.is_empty(), "{result:?}"); + let again = suggest(&deals, ¶ms, &SearchHandle::new()).expect("a result"); + assert_eq!(again.values, result.values, "{again:?}"); + assert!( + (again.train.profit - 40.0).abs() < 1e-6, + "{}", + again.train.profit + ); } /// A floor no point can hold is not an answer: the search says it found nothing rather than diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs index 57b9f670..82572b7e 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs @@ -8,7 +8,9 @@ //! this machine and the searched strategy's values, cut into `MOON_TICKS_STEPS` steps (the //! axis default when unset); `MOON_TICKS_GRIDS=legacy` searches the ladders the axis used before //! 2026-09-25 instead, so the two can be held against each other on the same deals. -//! `MOON_TICKS_SEARCH_ALL=1` searches the whole exit rather than the Delta Modifiers section. +//! `MOON_TICKS_SEARCH_ALL=1` searches the whole exit rather than the Delta Modifiers section; +//! `MOON_TICKS_SEARCH_ENTRY` searches the Entry and Exit groups whole, alone or together +//! ([`run_groups`]). use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -22,7 +24,8 @@ use crate::db::tuner::ticks::params::range::{ }; pub(super) use crate::db::tuner::ticks::search::PreparedDeal; use crate::db::tuner::ticks::search::{ - DEFAULT_MAX_PASSES, SearchParams, clip_to_horizon, common_horizon_ms, suggest, variant_tally, + DEFAULT_MAX_PASSES, SearchParams, check_corridors, clip_to_horizon, common_horizon_ms, + point_cost, search_size, suggest, train_len, variant_picture, variant_tally, }; use crate::db::tuner::ticks::{Deal, ModelSettings, TICK_PARAMS}; use crate::feed::types::Tick; @@ -56,7 +59,12 @@ pub(super) fn run(deals: Vec, kind: &str, defaults: &HashMap> = by_strategy.into_values().collect(); groups.sort_by_key(|g| std::cmp::Reverse(g.len())); - for group in groups.into_iter().take(5) { + // `MOON_TICKS_SEARCH_TOP=`: fewer strategies, for a search long enough to time on one. + let top = std::env::var("MOON_TICKS_SEARCH_TOP") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5); + for group in groups.into_iter().take(top) { run_one(group, kind, defaults); } } @@ -71,23 +79,26 @@ fn run_one(mut deals: Vec, kind: &str, defaults: &HashMap = TICK_PARAMS - .iter() - .filter(|f| !whole_exit && f.section != ParamSection::DeltaModifiers) - .map(|f| f.key.to_string()) - .collect(); let restarts = std::env::var("MOON_TICKS_SEARCH_RESTARTS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(10); - let held = HashMap::new(); let legacy = std::env::var("MOON_TICKS_GRIDS").is_ok_and(|v| v == "legacy"); let grids = if legacy { crate::db::tuner::ticks::search::test_grids::legacy().clone() } else { auto_grids(kind, &deals[0].own, defaults) }; + if let Ok(mode) = std::env::var("MOON_TICKS_SEARCH_ENTRY") { + return run_groups(&deals, kind, defaults, &grids, restarts, &mode); + } + let whole_exit = std::env::var_os("MOON_TICKS_SEARCH_ALL").is_some(); + let locked: HashSet = TICK_PARAMS + .iter() + .filter(|f| !whole_exit && f.section != ParamSection::DeltaModifiers) + .map(|f| f.key.to_string()) + .collect(); + let held = HashMap::new(); let params = SearchParams { held: &held, defaults, @@ -151,6 +162,219 @@ fn run_one(mut deals: Vec, kind: &str, defaults: &HashMap`: the whole Entry and Exit groups, every field free, as "Search +/// all" runs them — `entry`, `exit` or `both` from the strategy as it stands; `all` runs (a) the +/// entry, (b) the exit held over (a)'s answer and (c) both at once. +/// `MOON_TICKS_KEEP_CORRIDOR=0` drops the corridor guard the axis keeps on by default. The +/// search's own `[x] ticks search:` line — the base, the refusals — is printed beside each. +fn run_groups( + deals: &[PreparedDeal], + kind: &str, + defaults: &HashMap, + grids: &Grids, + restarts: usize, + mode: &str, +) { + let _ = env_logger::Builder::new() + .filter_level(log::LevelFilter::Off) + .filter_module( + crate::diagnostics::TICKS_AXIS_TARGET, + log::LevelFilter::Info, + ) + .is_test(true) + .try_init(); + eprintln!( + "search {kind} strategy {} on core {}: {} deal(s), {restarts} restart(s), mode {mode}", + deals[0].deal.strategy_id, + deals[0].deal.core_name, + deals.len() + ); + let model = ModelSettings::default(); + let whole = |values: &[(String, String)]| { + let (tally, _) = variant_tally(deals, defaults, kind, values, model); + (tally.n, (tally.profit * 1000.0).round() / 1000.0) + }; + // One training slice and one holdout for every point of the run: a search held over another's + // answer drops the deals that answer leaves open (`closing::closable_at_base`) and cuts its + // own slice, so the figures the searches report are not on the same deals. + let closes: Vec = deals.iter().map(|d| d.deal.close_ms).collect(); + let (train, holdout) = deals.split_at(train_len(&closes, 0.7)); + // `variant_tally` drops a deal the point leaves open and holds no corridor rule, where the + // search refuses such a point outright: the figure carries both counts, so a point the search + // would refuse reads as one (`open`, `nearer`, `inverted` not all zero). + let on = |slice: &[PreparedDeal], values: &[(String, String)]| { + let (tally, _) = variant_tally(slice, defaults, kind, values, model); + let open = slice + .iter() + .filter(|d| { + variant_picture(d, defaults, kind, values, model) + .outcome + .left_open() + }) + .count(); + let corridor = check_corridors( + slice.iter().map(|d| (&d.deal, d.own.as_ref())), + defaults, + values, + model, + ); + format!( + "(n {}, profit {:.3}, open {open}, nearer {}, inverted {})", + tally.n, tally.profit, corridor.nearer, corridor.inverted + ) + }; + eprintln!( + " as they stand, whole sample (n, profit): {:?}", + whole(&[]) + ); + let step = |label: &str, held: &HashMap, entry: bool, exit: bool| { + let (answer, ms) = search_groups(deals, kind, defaults, grids, restarts, held, entry, exit); + let found = match answer { + Ok(found) => found.values, + Err(miss) => { + eprintln!(" {label}: no answer {miss:?}, {ms} ms"); + Vec::new() + } + }; + let mut merged = held.clone(); + merged.extend(found.iter().cloned()); + let mut merged: Vec<(String, String)> = merged.into_iter().collect(); + merged.sort(); + eprintln!( + " {label}: entry moved {:?} · fixed train {} holdout {} · {ms} ms", + entry_fields(&found), + on(train, &merged), + on(holdout, &merged) + ); + (found, merged, ms) + }; + let none = HashMap::new(); + match mode { + "entry" => drop(step("(a) entry", &none, true, false)), + "exit" => drop(step("(exit) exit alone", &none, false, true)), + "both" => drop(step("(c) both", &none, true, true)), + "all" => { + let (a_found, a, _) = step("(a) entry", &none, true, false); + let (_, seq, _) = step("(b) exit over (a)", &a.into_iter().collect(), false, true); + let (c, _, _) = step("(c) both", &none, true, true); + // Is (c) a coordinate-wise optimum the sequence beats? The sequence's point, (c)'s, + // and (c)'s exit with (a)'s entry — the entry move away from (c) the descent would + // have to take — on the one training slice. + let is_entry = + |key: &str| !entry_fields(&[(key.to_string(), String::new())]).is_empty(); + let mut mixed: Vec<(String, String)> = + c.iter().filter(|(k, _)| !is_entry(k)).cloned().collect(); + mixed.extend(a_found.iter().filter(|(k, _)| is_entry(k)).cloned()); + eprintln!( + " train: sequence (a)+(b) {} · (c) {} · (c)'s exit with (a)'s entry {}", + on(train, &seq), + on(train, &c), + on(train, &mixed) + ); + } + other => { + eprintln!(" unknown MOON_TICKS_SEARCH_ENTRY {other:?}: entry|exit|both|all") + } + } +} + +/// One search of the chosen groups over `held`, every field of them free, and how long it took. +#[allow(clippy::too_many_arguments)] +fn search_groups( + deals: &[PreparedDeal], + kind: &str, + defaults: &HashMap, + grids: &Grids, + restarts: usize, + held: &HashMap, + vary_entry: bool, + vary_exit: bool, +) -> ( + Result< + crate::db::tuner::ticks::search::SearchResult, + crate::db::tuner::ticks::search::SearchMiss, + >, + u128, +) { + let locked = HashSet::new(); + let params = SearchParams { + held, + defaults, + kind, + vary_entry, + vary_exit, + locked: &locked, + grids, + restarts, + min_n: None, + seed: Some(1), + train_frac: 0.7, + max_passes: DEFAULT_MAX_PASSES, + model: ModelSettings::default(), + keep_corridor: std::env::var("MOON_TICKS_KEEP_CORRIDOR").map_or(true, |v| v != "0"), + }; + // What the axis would say before the run: the count and, at the measured cost of a point, the + // time — held against what the run then took. `MOON_TICKS_SEARCH_DRY=1` stops there. + let size = search_size(¶ms); + let cost = point_cost( + deals, + defaults, + kind, + params.model, + params.train_frac, + params.restarts, + ); + eprintln!( + " estimate: {:.0} point(s), {:.0} entry point(s), {:?} a point, ≈ {:.1} s", + size.points, + size.entry_points, + cost, + size.time(cost).as_secs_f64() + ); + if std::env::var_os("MOON_TICKS_SEARCH_DRY").is_some() { + return (Err(crate::db::tuner::ticks::search::SearchMiss::Nothing), 0); + } + let started = Instant::now(); + let answer = suggest(deals, ¶ms, &SearchHandle::new()); + let ms = started.elapsed().as_millis(); + if let Ok(found) = &answer { + eprintln!( + " actual: {} point(s) scored, {} entry point(s), {:.1} s, {:?} a scored point", + found.stats.evaluations, + found.stats.entry_points, + ms as f64 / 1000.0, + started.elapsed() / found.stats.evaluations.max(1) as u32 + ); + } + match &answer { + Ok(found) => eprintln!( + " found {:?}\n train n {} profit {:.3} · holdout {:?} · {:?}", + found.values, + found.train.n, + found.train.profit, + found + .holdout + .as_ref() + .map(|t| (t.n, (t.profit * 1000.0).round() / 1000.0)), + found.stats + ), + Err(miss) => eprintln!(" no answer: {miss:?}"), + } + (answer, ms) +} + +/// The Entry group's fields among a search's answer. +fn entry_fields(values: &[(String, String)]) -> Vec<&(String, String)> { + values + .iter() + .filter(|(key, _)| { + TICK_PARAMS.iter().any(|f| { + f.key == key && f.group == crate::db::tuner::ticks::params::ParamGroup::Entry + }) + }) + .collect() +} + /// The axis' automatic grids for one strategy: the live strategies of `kind` on this machine, the /// strategy's own values as the selection, `MOON_TICKS_STEPS` steps per field. fn auto_grids(kind: &str, own: &HashMap, defaults: &HashMap) -> Grids { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs index d6f76fcc..671999fd 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs @@ -69,15 +69,24 @@ impl AnalyticsView { let (status, status_color) = match &self.ticks.sugg { // Counted against the restarts the run was launched with, not the box's current // text. - SuggState::Running { handle, total } => ( - t!( + SuggState::Running { handle, total } => { + let progress = t!( "analytics.tuner.sugg_progress", done = handle.completed(), total = total ) - .to_string(), - p.text_soft, - ), + .to_string(); + // A search of both groups: its entry points, each with a whole exit search, move + // long before a restart ends. + let progress = match handle.points() { + 0 => progress, + n => format!( + "{progress} · {}", + t!("analytics.ticks.stats_entry_points", n = n) + ), + }; + (progress, p.text_soft) + } // A note first; else how the last search went, so a restart or pass count that // changed nothing can be seen to have changed nothing. SuggState::Idle => match (&self.ticks.sugg_note, &self.ticks.last_result) { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs new file mode 100644 index 00000000..bab765f4 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs @@ -0,0 +1,429 @@ +//! What "Search all" will cost before it runs (LinKvo, 2026-09-25): the points it scores and, +//! at what one point costs on this sample, roughly how long — a line under the parameter grid, +//! and a question before a run longer than [`LONG_SEARCH`]. A search of both groups nests a whole +//! exit search under every entry point it scores (`moon_core::db::tuner::ticks::search`), and its +//! count is the product: minutes to hours where one group takes seconds. +//! +//! The count is the core's (`search_size`). The cost of a point is measured, never assumed, and +//! kept with what it was measured under ([`CostKey`]: the rows, the training share, the model, +//! the restarts side by side): replays of the training slice run side by side as a search runs +//! its restarts (`point_cost`), measured again whenever one of those moves, and taken from every +//! finished search — its time over the points it scored, the same quantity. + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use gpui::*; +use moon_ui::{MoonButton, MoonButtonVariant, MoonPalette, MoonWindowExt as _, h_flex}; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +use super::model_cfg; +use super::tape::prepare_sample; +use super::variants::{passes_of, restarts_of}; +use crate::design; +use crate::design::moon; +use moon_core::db::tuner::ticks::params::ParamGroup; +use moon_core::db::tuner::ticks::search::{SearchParams, SearchSize, point_cost, search_size}; +use moon_core::db::tuner::ticks::{ModelSettings, TICK_PARAMS}; + +/// A search estimated to run longer than this asks before it starts. +pub(super) const LONG_SEARCH: Duration = Duration::from_secs(10 * 60); + +/// How long the sample must stand still before its point cost is measured: rows join it one by +/// one while their tape is read, and each would start a measurement of its own. +const COST_DEBOUNCE: Duration = Duration::from_millis(600); + +/// What a point cost is measured under: a change of any of them prices the point again. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::analytics::tuner) struct CostKey { + /// The load the rows came from (`TicksState::seq`): another scope of as many rows is + /// another sample. + pub load: u64, + /// Replayable rows of the sample. + pub n: usize, + /// The training share, per cent: a point replays that slice. + pub train_pct: usize, + /// The model's settings every replay runs under. + pub model: ModelSettings, + /// Restarts run side by side ([`side_by_side`]). + pub parallel: usize, +} + +/// How many of a search's restarts run side by side: the machine's threads bound them. +fn side_by_side(restarts: usize) -> usize { + let threads = std::thread::available_parallelism().map_or(1, |n| n.get()); + restarts.clamp(1, threads) +} + +/// What one search varies: whether each group is searched, and the fields it holds. +pub(super) struct Scope { + pub vary_entry: bool, + pub vary_exit: bool, + pub locked: HashSet, +} + +impl AnalyticsView { + /// The scope of a search of `only`, or of every ticked field: every ticked field of the + /// groups the gate lets through, or the one field with every other held. `Err` carries the + /// locale key of why it cannot start — `None` for a field the grid does not know. + pub(super) fn ticks_search_scope( + &self, + only: Option<&'static str>, + ) -> Result> { + let (vary_entry, vary_exit, locked) = match only { + None => ( + self.ticks_group_searchable(ParamGroup::Entry), + self.ticks_group_searchable(ParamGroup::Exit), + self.ticks.locked.clone(), + ), + Some(key) => { + let field = TICK_PARAMS.iter().find(|f| f.key == key).ok_or(None)?; + if !model_cfg::current().entry_method.reads(key) { + return Err(Some("analytics.ticks.sugg_not_read")); + } + if !self.ticks_group_searchable(field.group) { + return Err(Some("analytics.ticks.sugg_gated")); + } + let locked = TICK_PARAMS + .iter() + .map(|f| f.key) + .filter(|k| *k != key) + .map(str::to_string) + .collect(); + ( + field.group == ParamGroup::Entry, + field.group == ParamGroup::Exit, + locked, + ) + } + }; + if !(vary_entry || vary_exit) { + return Err(Some("analytics.ticks.sugg_nothing")); + } + Ok(Scope { + vary_entry, + vary_exit, + locked, + }) + } + + /// The size of the search `only` names over the grids as they stand, or `None` when it + /// cannot start or the scope holds more than one kind. + pub(super) fn ticks_search_size(&self, only: Option<&'static str>) -> Option { + let scope = self.ticks_search_scope(only).ok()?; + let kind = self.ticks.data.data()?.single_kind()?.to_string(); + let (grids, _) = self.ticks_search_grids(); + let (held, defaults) = (HashMap::new(), HashMap::new()); + Some(search_size(&SearchParams { + held: &held, + defaults: &defaults, + kind: &kind, + vary_entry: scope.vary_entry, + vary_exit: scope.vary_exit, + locked: &scope.locked, + grids: &grids, + restarts: restarts_of(&self.ticks.iters), + min_n: None, + seed: None, + train_frac: 1.0, + max_passes: passes_of(&self.ticks.passes), + model: model_cfg::current(), + keep_corridor: self.ticks.keep_corridor, + })) + } + + /// What a point cost measured now would be measured under — the rows, the training share, + /// the model's settings and the restarts run side by side — or `None` before a sample. + pub(super) fn ticks_cost_key(&self) -> Option { + let n = self.ticks.data.data()?.replayable().count(); + (n > 0).then(|| CostKey { + load: self.ticks.seq, + n, + train_pct: self.ticks.train_pct, + model: model_cfg::current(), + parallel: side_by_side(restarts_of(&self.ticks.iters)), + }) + } + + /// Roughly how long a search of `size` runs, when a point's cost was measured under the + /// settings as they stand. + pub(super) fn ticks_search_time(&self, size: &SearchSize) -> Option { + let key = self.ticks_cost_key()?; + let (at, cost) = self.ticks.point_cost?; + (at == key).then(|| size.time(cost)) + } + + /// The line under the parameter grid: what "Search all" scores and roughly how long it + /// takes, amber past [`LONG_SEARCH`]. Nothing when no search could start. + pub(super) fn ticks_estimate_row(&self, p: MoonPalette, cx: &App) -> Option { + let size = self.ticks_search_size(None)?; + let time = self.ticks_search_time(&size); + let time_text = time.map_or_else( + || t!("analytics.ticks.est_time_pending").to_string(), + duration_text, + ); + let text = if size.nested() { + t!( + "analytics.ticks.est_nested", + points = count_text(size.points), + entry = count_text(size.entry_points), + time = time_text + ) + } else { + t!( + "analytics.ticks.est_line", + points = count_text(size.points), + time = time_text + ) + } + .to_string(); + let long = time.is_some_and(|t| t > LONG_SEARCH); + Some( + div() + .id("an-ticks-estimate") + .w_full() + .flex_none() + .px(design::ui_px(cx, 12.0)) + .py(design::ui_px(cx, 4.0)) + .border_t_1() + .border_color(moon(p.border)) + // Wrapped, left-aligned, two lines at most: the nested line does not fit one + // (LinKvo, 2026-09-25). A tail past them ends in an ellipsis — the time leads the + // line, so it is never the part cut — and the tooltip carries the whole line. + .whitespace_normal() + .text_left() + .line_clamp(2) + .text_ellipsis() + .text_size(design::t_caption(cx)) + .font_family(design::ui_font()) + .text_color(moon(if long { p.amber } else { p.text_muted })) + .tooltip(crate::panels::common::text_tooltip(format!( + "{text}\n\n{}", + t!("analytics.ticks.est_tip") + ))) + .child(text) + .into_any_element(), + ) + } + + /// Measure what a point costs under the settings as they stand, once they stop moving — + /// asked by every paint of the grid, so a change of the rows, the training share, the model + /// or the restarts is priced without each of them saying so. A cost already measured or being + /// measured under them, a sample still reading its tape, and a running search — which answers + /// the cost itself, and would slow the measurement — are left alone. + pub(super) fn ticks_measure_cost(&mut self, cx: &mut Context) { + if self.ticks.tape_reading + || matches!(self.ticks.sugg, super::state::SuggState::Running { .. }) + { + return; + } + let Some(key) = self.ticks_cost_key() else { + return; + }; + if self.ticks.point_cost.is_some_and(|(at, _)| at == key) + || self.ticks.cost_pending == Some(key) + { + return; + } + self.ticks.cost_pending = Some(key); + self.ticks.cost_task = Some(cx.spawn(async move |this, cx| { + let executor = cx.update(|cx| cx.background_executor().clone()); + executor.timer(COST_DEBOUNCE).await; + let _ = cx.update(|cx| { + let _ = this.update(cx, |this, cx| this.run_ticks_cost(key, cx)); + }); + })); + } + + /// Time the replay of the sample's training slice, off the UI thread. Settings that moved + /// meanwhile, or a search started meanwhile — the two would have shared the pool — drop the + /// answer; the next paint asks again. + fn run_ticks_cost(&mut self, key: CostKey, cx: &mut Context) { + let searching = + |this: &Self| matches!(this.ticks.sugg, super::state::SuggState::Running { .. }); + let kind = self + .ticks + .data + .data() + .and_then(|d| d.single_kind().map(String::from)); + let (Some(kind), false, Some(true)) = ( + kind, + searching(self), + self.ticks_cost_key().map(|now| now == key), + ) else { + self.ticks.cost_pending = None; + return; + }; + let pending = self.prepared_deals(); + let defaults = self.filter_defaults(cx); + let train_frac = super::super::filter::state::train_frac(key.train_pct); + let seq = self.ticks.sugg_seq; + self.spawn_db( + false, + cx, + move || { + let deals = prepare_sample(pending); + point_cost( + &deals, + &defaults, + &kind, + key.model, + train_frac, + key.parallel, + ) + }, + move |this, cost, cx| { + if this.ticks.cost_pending == Some(key) { + this.ticks.cost_pending = None; + } + let still = this.ticks_cost_key() == Some(key) + && this.ticks.sugg_seq == seq + && !searching(this); + if still && cost > Duration::ZERO { + this.ticks.point_cost = Some((key, cost)); + } + cx.notify(); + }, + ); + } + + /// Take a finished search's time over the points it scored as the point cost under the + /// settings it started with: it ran the restarts side by side, as the next search will. A + /// search of a few points is left out — the replays it runs besides its points (the sample's + /// filter, the base, the holdout) would weigh on the figure. + pub(super) fn ticks_take_search_cost( + &mut self, + key: Option, + elapsed: Duration, + scored: usize, + ) { + /// Points a search must score before its time says what one costs. + const MIN_SCORED: usize = 200; + if let (Some(key), true) = (key, scored >= MIN_SCORED) { + self.ticks.point_cost = Some((key, elapsed / scored.min(u32::MAX as usize) as u32)); + } + } + + /// Ask before a search estimated past [`LONG_SEARCH`] — or a nested one whose point cost is + /// not measured yet — how long it will take; answers whether it asked, the search then waits + /// for Run. + pub(super) fn ticks_confirm_long_search( + &mut self, + only: Option<&'static str>, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(size) = self.ticks_search_size(only) else { + return false; + }; + let time = self.ticks_search_time(&size); + let body = match time { + Some(time) if time > LONG_SEARCH => t!( + "analytics.ticks.long_body", + time = duration_text(time), + points = count_text(size.points) + ), + None if size.nested() => t!( + "analytics.ticks.long_body_unknown", + points = count_text(size.points) + ), + _ => return false, + } + .to_string(); + let view = cx.entity(); + window.open_unique_moon_dialog("an-ticks-long-dialog", cx, move |dialog, _window, cx| { + let p = MoonPalette::active(cx); + let body = body.clone(); + let go = view.clone(); + dialog + .w(design::font_w_px(cx, 420.0)) + .close_button(false) + .overlay(true) + .overlay_closable(true) + .bg(moon(p.shell_high)) + .border_color(moon(p.border)) + .rounded(design::r_container(cx)) + .text_color(moon(p.text)) + .header( + div() + .w_full() + .py_2() + .border_b_1() + .border_color(moon(p.border)) + .font_weight(FontWeight::SEMIBOLD) + .child(t!("analytics.ticks.long_title").to_string()), + ) + .content(move |content, _window, cx| { + content.child( + div() + .w_full() + .font_family(design::ui_font()) + .text_size(design::t_body(cx)) + .child(body.clone()), + ) + }) + .footer( + h_flex() + .w_full() + .justify_end() + .gap(design::ui_px(cx, 8.0)) + .font_family(design::ui_font()) + .child( + MoonButton::new("an-ticks-long-cancel") + .variant(MoonButtonVariant::Ghost) + .label(t!("dialogs.cancel").to_string()) + .on_click(|_, window, cx| window.close_dialog(cx)) + .render(), + ) + .child( + MoonButton::new("an-ticks-long-go") + .variant(MoonButtonVariant::Blue) + .label(t!("analytics.ticks.long_go").to_string()) + .on_click(move |_, window, cx| { + window.close_dialog(cx); + go.update(cx, |this, cx| { + this.ticks_search_confirmed(only, window, cx) + }); + }) + .render(), + ), + ) + }); + true + } +} + +/// A count with its thousands apart: `4 456 380`. +fn count_text(value: f64) -> String { + let digits = format!("{:.0}", value.max(0.0)); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push('\u{202f}'); + } + out.push(c); + } + out +} + +/// A duration as a person reads it: seconds under a minute, minutes under an hour, else hours +/// and minutes. +pub(super) fn duration_text(duration: Duration) -> String { + let secs = duration.as_secs(); + if secs < 60 { + t!("analytics.ticks.dur_s", s = secs.max(1)).to_string() + } else if secs < 3600 { + t!("analytics.ticks.dur_m", m = secs.div_ceil(60)).to_string() + } else { + t!( + "analytics.ticks.dur_hm", + h = secs / 3600, + m = (secs % 3600) / 60 + ) + .to_string() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate/tests.rs new file mode 100644 index 00000000..48e09708 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate/tests.rs @@ -0,0 +1,23 @@ +use std::time::Duration; + +use super::{count_text, duration_text}; + +#[test] +fn a_count_keeps_its_thousands_apart() { + assert_eq!(count_text(0.0), "0"); + assert_eq!(count_text(999.4), "999"); + assert_eq!(count_text(1000.0), "1\u{202f}000"); + assert_eq!(count_text(4_456_380.0), "4\u{202f}456\u{202f}380"); +} + +#[test] +fn a_duration_reads_in_the_largest_unit_it_needs() { + let _locale = crate::test_locale::force("en"); + assert_eq!(duration_text(Duration::from_millis(200)), "1 s"); + assert_eq!(duration_text(Duration::from_secs(59)), "59 s"); + assert_eq!(duration_text(Duration::from_secs(61)), "2 min"); + assert_eq!( + duration_text(Duration::from_secs(3 * 3600 + 5 * 60)), + "3 h 5 min" + ); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index 251aa5ea..fa47d8a7 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -127,6 +127,10 @@ impl AnalyticsView { }); } } + // Under the fields: what "Search all" over the ticked ones scores and how long it takes — + // priced again whenever what a point costs under may have moved (`estimate.rs`). + self.ticks_measure_cost(cx); + let estimate = self.ticks_estimate_row(p, cx); v_flex() .w_full() .flex_1() @@ -147,6 +151,7 @@ impl AnalyticsView { .overflow_y_scroll() .child(grid), ) + .children(estimate) .into_any_element() } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index 9991ff17..1edd4943 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -38,6 +38,7 @@ use state::{DealRow, TapeStatus}; mod cfg; pub(in crate::analytics::tuner) mod columns; mod delta_summary; +mod estimate; pub(crate) mod fetch; mod grid; mod lags; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 4d942869..e8c382b8 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -259,6 +259,7 @@ fn invalidate_stops_the_search_and_drops_the_variant_scores_but_keeps_the_edits( }; state.last_result = Some(moon_core::db::tuner::ticks::SearchResult { values: Vec::new(), + searched: Vec::new(), train: Default::default(), holdout: Some(Default::default()), holdout_open: 0, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 11fa1b06..0498bf03 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -433,6 +433,15 @@ pub(in crate::analytics) struct TicksState { pub(in crate::analytics::tuner) schema_reload: Option, /// The grid's sections the user opened; every section starts folded (`grid.rs`). pub(in crate::analytics::tuner) open_sections: HashSet, + /// What one scored point of a search costs, with what it was measured under — the time the + /// estimate under the grid prints (`estimate.rs`). Measured when what it is measured under + /// changes, and taken again from every finished search. + pub(in crate::analytics::tuner) point_cost: + Option<(super::estimate::CostKey, std::time::Duration)>, + /// What the measurement in flight is measured under, so a paint does not start another. + pub(in crate::analytics::tuner) cost_pending: Option, + /// The pending measurement of `point_cost`; dropping it cancels it. + pub(in crate::analytics::tuner) cost_task: Option>, } impl Default for TicksState { @@ -481,6 +490,9 @@ impl Default for TicksState { keys_sig: None, schema_reload: None, open_sections: HashSet::new(), + point_cost: None, + cost_pending: None, + cost_task: None, } } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs index 04473e83..0aa75eef 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs @@ -136,12 +136,21 @@ impl AnalyticsView { /// Open the warning before a search when a strategy of the scope switches on an exit field /// the model does not have; answers whether it opened — the search then waits for Continue. + /// Only a search that varies an exit field is warned: the fields listed are the exit's, and a + /// search of the entry alone does not tune them (LinKvo, 2026-09-25: "I search the entry, and + /// it warns me about exit fields that take no part"). pub(super) fn ticks_warn_before_search( &mut self, only: Option<&'static str>, window: &mut Window, cx: &mut Context, ) -> bool { + if !self + .ticks_search_size(only) + .is_some_and(|size| size.exit_fields > 0) + { + return false; + } // A search runs on the loaded scope's deals, so a strategy the load has not read is not // one it searches: only the rows speak here. let (rows, _) = self.ticks_unmodelled_rows(self.ticks_search_strategies(), cx); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 4b6bbcda..8a009d00 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -14,7 +14,7 @@ //! own settings: the entry fills where the report says, and the stop fires when and where the //! core's did (`record::StopAnchor`) — the book the tape does not carry, answered by the fact. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -26,7 +26,6 @@ use super::state::SuggState; use super::tape::{PendingDeal, prepare_sample}; use crate::analytics::bg::ReadLane; use moon_core::db::tuner::threshold_search::SearchHandle; -use moon_core::db::tuner::ticks::TICK_PARAMS; use moon_core::db::tuner::ticks::params::{self, ParamGroup}; use moon_core::db::tuner::ticks::search::{ DEFAULT_MAX_PASSES, SearchMiss, SearchParams, check_corridors, suggest, train_len, @@ -71,7 +70,7 @@ impl AnalyticsView { /// The replayable rows as the search and the columns take them, their tapes still packed: /// the caller hands them to [`prepare_sample`] off the UI thread, which unpacks them and /// cuts every tape at the sample's one exit horizon. - fn prepared_deals(&self) -> Vec { + pub(super) fn prepared_deals(&self) -> Vec { self.ticks .data .data() @@ -239,8 +238,9 @@ impl AnalyticsView { ) } - /// "Search all": every ticked field of the groups the gate lets through, from В1 as it - /// stands and into it ([`land_answer`]). + /// "Search all": every ticked field of the groups the gate lets through, each from the + /// strategies, the unticked ones held at В1's value; the answer goes into В1 + /// ([`land_answer`]). pub(in crate::analytics::tuner) fn ticks_suggest( &mut self, window: &mut Window, @@ -249,8 +249,9 @@ impl AnalyticsView { self.ticks_run_search(None, window, cx); } - /// "Search": the selected field alone, ticked or not, the rest of В1 held as it stands; the - /// answer goes into that cell of В1 and the cells of the values it completed ([`land_answer`]). + /// "Search": the selected field alone, ticked or not, from the strategies, the rest of В1 held + /// as it stands; the answer goes into that cell of В1 — emptied when it is the strategies' + /// own — and the cells of the values it completed ([`land_answer`]). pub(in crate::analytics::tuner) fn ticks_suggest_one( &mut self, window: &mut Window, @@ -261,8 +262,9 @@ impl AnalyticsView { } } - /// A search asked for: first the warning when a strategy it runs on switches on an exit - /// field the model does not have (`unmodelled.rs`) — the search then starts on its Continue. + /// A search asked for: first the question when it is estimated long (`estimate.rs`), then + /// the warning when a strategy it runs on switches on an exit field the model does not have + /// (`unmodelled.rs`) — the search then starts on their answers. fn ticks_run_search( &mut self, only: Option<&'static str>, @@ -272,6 +274,18 @@ impl AnalyticsView { if matches!(self.ticks.sugg, SuggState::Running { .. }) { return; } + if !self.ticks_confirm_long_search(only, window, cx) { + self.ticks_search_confirmed(only, window, cx); + } + } + + /// A search past the question of its length: the warning, then the start. + pub(super) fn ticks_search_confirmed( + &mut self, + only: Option<&'static str>, + window: &mut Window, + cx: &mut Context, + ) { if !self.ticks_warn_before_search(only, window, cx) { self.ticks_start_search(only, cx); } @@ -290,7 +304,8 @@ impl AnalyticsView { } /// Run the search into В1: over every ticked field (`only` = `None`), or over one field with - /// every other held — at the strategies' value, or at В1's where В1 changes it. + /// every other held — at the strategies' value, or at В1's where В1 changes it. A searched + /// field starts from the strategies whatever В1 holds for it. pub(super) fn ticks_start_search( &mut self, only: Option<&'static str>, @@ -307,43 +322,20 @@ impl AnalyticsView { return self.ticks_search_refused("analytics.ticks.sugg_one_kind", cx); }; let model = super::model_cfg::current(); - // Laid over every deal's own strategy before the search's point: В1 as it stands, - // whatever is searched (the developer, 2026-09-25). A search runs on from what the - // earlier ones found — В1's fields are its base, its unticked ones held at В1's value — - // not from the strategy as if В1 were empty. + // Laid over every deal's own strategy before the search's point: В1 as it stands. The + // fields the search leaves alone run at В1's value; the searched ones start from the + // strategies whatever В1 holds for them — the search sets their values aside + // (`SearchParams::held`, LinKvo 2026-09-25). let held: HashMap = self.ticks.variant_changes().into_iter().collect(); - let (vary_entry, vary_exit, locked) = match only { - None => ( - self.ticks_group_searchable(ParamGroup::Entry), - self.ticks_group_searchable(ParamGroup::Exit), - self.ticks.locked.clone(), - ), - Some(key) => { - let Some(field) = TICK_PARAMS.iter().find(|f| f.key == key) else { - return; - }; - if !model.entry_method.reads(key) { - return self.ticks_search_refused("analytics.ticks.sugg_not_read", cx); - } - if !self.ticks_group_searchable(field.group) { - return self.ticks_search_refused("analytics.ticks.sugg_gated", cx); - } - let locked: HashSet = TICK_PARAMS - .iter() - .map(|f| f.key) - .filter(|k| *k != key) - .map(str::to_string) - .collect(); - ( - field.group == ParamGroup::Entry, - field.group == ParamGroup::Exit, - locked, - ) - } + let super::estimate::Scope { + vary_entry, + vary_exit, + locked, + } = match self.ticks_search_scope(only) { + Ok(scope) => scope, + Err(Some(key)) => return self.ticks_search_refused(key, cx), + Err(None) => return, }; - if !(vary_entry || vary_exit) { - return self.ticks_search_refused("analytics.ticks.sugg_nothing", cx); - } if pending.is_empty() { return self.ticks_search_refused("analytics.ticks.sugg_no_tape", cx); } @@ -399,6 +391,8 @@ impl AnalyticsView { probe::watch(handle.clone(), restarts, seq); self.poll_ticks_search(handle.clone(), seq, cx); let started = std::time::Instant::now(); + // What the point cost the answer brings is measured under (`estimate.rs`). + let cost_key = self.ticks_cost_key(); self.spawn_latest_db( &[ReadLane::TicksSearch], false, @@ -421,9 +415,13 @@ impl AnalyticsView { model, keep_corridor, }; - suggest(&deals, ¶ms, &handle) + // The search alone is timed for the point cost: the queue and the unpacking of + // the tapes above are no point's. + let searching = std::time::Instant::now(); + let result = suggest(&deals, ¶ms, &handle); + (result, searching.elapsed()) }, - move |this, result, cx| { + move |this, (result, searched_for), cx| { log::info!( target: moon_core::diagnostics::TICKS_AXIS_TARGET, "[x] ticks search: #{seq} answered after {} ms ({}), current #{}", @@ -440,7 +438,12 @@ impl AnalyticsView { this.ticks.sugg = SuggState::Idle; match result { Ok(result) => { - land_answer(&mut this.ticks.variant, &result.values); + this.ticks_take_search_cost( + cost_key, + searched_for, + result.stats.evaluations, + ); + land_answer(&mut this.ticks.variant, &result.searched, &result.values); this.ticks_reset_variant_inputs(); this.ticks.last_seed = Some(result.seed); this.ticks.last_result = Some(result); @@ -484,7 +487,8 @@ impl AnalyticsView { // The answer sets the row idle without a new generation. running = this.ticks.sugg_seq == seq && matches!(this.ticks.sugg, SuggState::Running { .. }); - let done = handle.completed(); + // A search of both groups moves its entry points long before a restart. + let done = (handle.completed(), handle.points()); if running && shown != Some(done) { shown = Some(done); cx.notify(); @@ -641,17 +645,22 @@ impl AnalyticsView { } } -/// Lay a search's answer over В1 — a search of one field or of every one alike. Every search runs -/// from В1 as it stands (`ticks_start_search`), and the answer holds what it moved off that: the -/// searched fields, and every value it completed for a switch it turned on (`search::deps` — -/// `UseTakeProfit` brings its `TakeProfit`), which the search scored and Save must write with -/// it. A field it left where В1 had it is not in the answer and keeps В1's cell. В1 is never -/// emptied here: only the user clears it (the developer, 2026-09-25). +/// Lay a search's answer over В1 — a search of one field or of every one alike. A searched field +/// is searched anew from the strategies, whatever В1 held for it (LinKvo, 2026-09-25), so its cell +/// takes the answer, or is emptied when the answer leaves it at the strategies' own value. The +/// fields the search left alone keep their cells, and so does every value it completed for a +/// switch it turned on (`search::deps` — `UseTakeProfit` brings its `TakeProfit`) by taking it +/// from the answer, which the search scored and Save must write with it. /// /// Args: /// v1: В1's cells. +/// searched: The fields the search varied +/// ([`SearchResult::searched`](moon_core::db::tuner::ticks::search::SearchResult)). /// values: The answer ([`SearchResult::values`](moon_core::db::tuner::ticks::search::SearchResult)). -fn land_answer(v1: &mut HashMap, values: &[(String, String)]) { +fn land_answer(v1: &mut HashMap, searched: &[String], values: &[(String, String)]) { + for key in searched { + v1.remove(key); + } v1.extend(values.iter().cloned()); } @@ -673,6 +682,14 @@ pub(super) fn search_stats_line(stats: &moon_core::db::tuner::ticks::SearchStats refused = stats.refused ) .to_string(); + // Both groups searched: how many entry points each ran a whole exit search. + let line = match stats.entry_points { + 0 => line, + n => format!( + "{line} · {}", + t!("analytics.ticks.stats_entry_points", n = n) + ), + }; // The deals taken out before the search: the sample it answers for is smaller by them. match stats.left_open { 0 => line, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs index 4f338890..e92cd457 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs @@ -18,14 +18,19 @@ fn answer(pairs: &[(&str, &str)]) -> Vec<(String, String)> { .collect() } +fn keys(names: &[&str]) -> Vec { + names.iter().map(|k| (*k).to_string()).collect() +} + /// "Search" on `UseTakeProfit`: the switch lands, and so does the `TakeProfit` the answer /// completed for it — Save would otherwise write the switch with no per cent behind it — while -/// every other cell of В1 stays. +/// every cell the search did not vary stays. #[test] fn a_search_of_one_field_lands_the_values_it_completed() { let mut v1 = cells(&[("SellPrice", "1.5"), ("StopLoss", "-3")]); land_answer( &mut v1, + &keys(&["UseTakeProfit"]), &answer(&[("TakeProfit", "1"), ("UseTakeProfit", "YES")]), ); assert_eq!( @@ -39,22 +44,28 @@ fn a_search_of_one_field_lands_the_values_it_completed() { ); } -/// A search of one field that left it at its base answers without it: В1's cell stays. +/// A searched field is searched anew from the strategies (LinKvo, 2026-09-25): an answer that +/// leaves it at the strategies' own value empties its cell, whatever В1 had put there. #[test] -fn a_field_left_at_its_base_keeps_what_v1_had() { +fn a_searched_field_left_at_the_strategy_empties_its_cell() { let mut v1 = cells(&[("SellPrice", "1.5")]); - land_answer(&mut v1, &[]); - assert_eq!(v1, cells(&[("SellPrice", "1.5")])); + land_answer(&mut v1, &keys(&["SellPrice"]), &[]); + assert!(v1.is_empty(), "{v1:?}"); } -/// "Search all" lays its answer over В1 and never empties it (the developer, 2026-09-25): a -/// search runs from В1 as it stands, so a cell it did not move is still В1's, and В1 is cleared -/// only by the user. +/// "Search all" replaces every searched cell by its answer and leaves the cells it did not +/// search as they were. #[test] -fn a_search_of_every_field_keeps_what_v1_had() { - let mut v1 = cells(&[("SellPrice", "1.5"), ("StopLoss", "-3")]); - land_answer(&mut v1, &answer(&[("SellPrice", "2")])); - assert_eq!(v1, cells(&[("SellPrice", "2"), ("StopLoss", "-3")])); - land_answer(&mut v1, &[]); - assert_eq!(v1, cells(&[("SellPrice", "2"), ("StopLoss", "-3")])); +fn a_search_of_every_ticked_field_replaces_them_and_keeps_the_rest() { + let mut v1 = cells(&[ + ("SellPrice", "1.5"), + ("StopLoss", "-3"), + ("MaxModifier", "5"), + ]); + land_answer( + &mut v1, + &keys(&["SellPrice", "StopLoss"]), + &answer(&[("SellPrice", "2")]), + ); + assert_eq!(v1, cells(&[("SellPrice", "2"), ("MaxModifier", "5")])); } diff --git a/locales/analytics.yml b/locales/analytics.yml index d7d1c914..20cd603a 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -2036,6 +2036,54 @@ analytics.ticks.stats_left_open: ru: "вне выборки сделок, которые стратегия как есть не закрывает внутри ленты: %{n}" en: "left out, deals the strategy as it stands does not close inside the tape: %{n}" es: "fuera de la muestra, operaciones que la estrategia tal cual no cierra dentro de la cinta: %{n}" +analytics.ticks.stats_entry_points: + ru: "точек входа %{n} (на каждую — подбор всего выхода)" + en: "entry points %{n} (each with the whole exit searched)" + es: "puntos de entrada %{n} (cada uno con toda la salida buscada)" +analytics.ticks.est_line: + ru: "«Подобрать всё»: ≈ %{time} · ≈ %{points} вариантов" + en: "\"Search all\": ≈ %{time} · ≈ %{points} variants" + es: "«Buscar todo»: ≈ %{time} · ≈ %{points} variantes" +analytics.ticks.est_nested: + ru: "«Подобрать всё», вход × выход: ≈ %{time} · ≈ %{points} вариантов (точек входа ≈ %{entry}, на каждую — весь выход)" + en: "\"Search all\", entry × exit: ≈ %{time} · ≈ %{points} variants (≈ %{entry} entry points, the whole exit under each)" + es: "«Buscar todo», entrada × salida: ≈ %{time} · ≈ %{points} variantes (≈ %{entry} puntos de entrada, toda la salida en cada uno)" +analytics.ticks.est_time_pending: + ru: "время замеряется" + en: "timing it" + es: "midiendo el tiempo" +analytics.ticks.est_tip: + ru: "Оценка по отмеченным полям, сеткам, перезапускам и проходам. Отмечены и вход, и выход — на каждую точку входа подбирается весь выход, время растёт кратно. Время — по замеру одной точки на этой выборке, после каждого подбора уточняется. Порядок, не точное число." + en: "An estimate from the ticked fields, the grids, restarts and passes. With both the entry and the exit ticked, the whole exit is searched under every entry point, and the time multiplies. The time is one point measured on this sample, refined after every search. An order of magnitude, not an exact figure." + es: "Estimación a partir de los campos marcados, las rejillas, los reinicios y las pasadas. Con la entrada y la salida marcadas, se busca toda la salida en cada punto de entrada y el tiempo se multiplica. El tiempo sale de medir un punto en esta muestra y se afina tras cada búsqueda. Un orden de magnitud, no una cifra exacta." +analytics.ticks.long_title: + ru: "Долгий подбор" + en: "A long search" + es: "Una búsqueda larga" +analytics.ticks.long_body: + ru: "Подбор займёт примерно %{time} (≈ %{points} вариантов). Запустить?" + en: "The search will take roughly %{time} (≈ %{points} variants). Run it?" + es: "La búsqueda tardará aproximadamente %{time} (≈ %{points} variantes). ¿Ejecutarla?" +analytics.ticks.long_body_unknown: + ru: "Подбор входа и выхода вместе: ≈ %{points} вариантов, время ещё не замерено — это может быть долго. Запустить?" + en: "Entry and exit searched together: ≈ %{points} variants, the time is not measured yet — it may take long. Run it?" + es: "Entrada y salida buscadas juntas: ≈ %{points} variantes, el tiempo aún no se midió; puede tardar. ¿Ejecutarla?" +analytics.ticks.long_go: + ru: "Запустить" + en: "Run" + es: "Ejecutar" +analytics.ticks.dur_s: + ru: "%{s} с" + en: "%{s} s" + es: "%{s} s" +analytics.ticks.dur_m: + ru: "%{m} мин" + en: "%{m} min" + es: "%{m} min" +analytics.ticks.dur_hm: + ru: "%{h} ч %{m} мин" + en: "%{h} h %{m} min" + es: "%{h} h %{m} min" analytics.ticks.stats_line: ru: "перезапусков %{restarts} · лучший — №%{best} · %{passes} · разных итогов %{distinct} · без разрешённой точки %{refused} · оценено точек %{evals}" en: "restarts %{restarts} · best from #%{best} · %{passes} · distinct ends %{distinct} · with no allowed point %{refused} · points scored %{evals}" @@ -2097,13 +2145,13 @@ analytics.ticks.not_read: en: "not read" es: "no se lee" analytics.ticks.suggest_one_tip: - ru: "Подобрать только выделенное поле %{field}, галочка не учитывается; остальные поля держатся как в В1. Ответ пишется в ячейку %{field} столбца В1 — и в поля, которые включённый переключатель требует (UseTakeProfit → TakeProfit)" - en: "Search the selected field %{field} alone, ticked or not; the other fields stay as V1 has them. The answer goes into V1's %{field} cell — and into the fields a switch it turns on needs (UseTakeProfit → TakeProfit)" - es: "Buscar solo el campo seleccionado %{field}, marcado o no; los demás campos quedan como en V1. La respuesta va a la celda %{field} de V1 — y a los campos que requiere un interruptor que active (UseTakeProfit → TakeProfit)" + ru: "Подобрать только выделенное поле %{field}, галочка не учитывается. Поле подбирается заново от значений стратегии, что бы ни стояло в В1; остальные поля держатся как в В1. Ответ пишется в ячейку %{field} столбца В1 (совпал со стратегией — ячейка очищается) — и в поля, которые включённый переключатель требует (UseTakeProfit → TakeProfit)" + en: "Search the selected field %{field} alone, ticked or not. The field is searched anew from the strategy's values, whatever V1 holds; the other fields stay as V1 has them. The answer goes into V1's %{field} cell (emptied when it is the strategy's own value) — and into the fields a switch it turns on needs (UseTakeProfit → TakeProfit)" + es: "Buscar solo el campo seleccionado %{field}, marcado o no. El campo se busca de nuevo desde los valores de la estrategia, tenga lo que tenga V1; los demás campos quedan como en V1. La respuesta va a la celda %{field} de V1 (se vacía si coincide con la de la estrategia) — y a los campos que requiere un interruptor que active (UseTakeProfit → TakeProfit)" analytics.ticks.suggest_all_tip: - ru: "Подобрать все поля с галочкой в группах, прошедших порог воспроизводимости, — сейчас их %{n}. Подбор идёт от того, что уже стоит в В1, и пишет найденное поверх В1 — остальные ячейки В1 не трогает (очистить В1 — только ✕). Поле без галочки держится как в В1, кроме значения, которое требует включённый подбором переключатель (UseTakeProfit → TakeProfit): оно одно на все стратегии" - en: "Search every ticked field of the groups past the reproduction gate — %{n} now. The search starts from what V1 already holds and writes what it finds over V1, leaving V1's other cells alone (only ✕ clears V1). An unticked field stays as V1 has it, but for a value a switch the search turns on needs (UseTakeProfit → TakeProfit): one value for every strategy" - es: "Buscar todos los campos marcados de los grupos que pasan el umbral de reproducción — ahora %{n}. La búsqueda parte de lo que ya tiene V1 y escribe lo hallado sobre V1, sin tocar sus demás celdas (solo ✕ vacía V1). Un campo sin marcar queda como en V1, salvo el valor que requiere un interruptor que la búsqueda active (UseTakeProfit → TakeProfit): un valor para todas las estrategias" + ru: "Подобрать все поля с галочкой в группах, прошедших порог воспроизводимости, — сейчас их %{n}. Поле с галочкой подбирается заново от значений стратегии, что бы ни стояло в В1; его ячейка В1 получает ответ, а совпал он со стратегией — очищается. Поле без галочки держится как в В1 и его ячейка не трогается, кроме значения, которое требует включённый подбором переключатель (UseTakeProfit → TakeProfit): оно одно на все стратегии" + en: "Search every ticked field of the groups past the reproduction gate — %{n} now. A ticked field is searched anew from the strategy's values, whatever V1 holds; its V1 cell takes the answer, and is emptied when the answer is the strategy's own value. An unticked field stays as V1 has it and its cell is left alone, but for a value a switch the search turns on needs (UseTakeProfit → TakeProfit): one value for every strategy" + es: "Buscar todos los campos marcados de los grupos que pasan el umbral de reproducción — ahora %{n}. Un campo marcado se busca de nuevo desde los valores de la estrategia, tenga lo que tenga V1; su celda de V1 recibe la respuesta y se vacía si coincide con la de la estrategia. Un campo sin marcar queda como en V1 y su celda no se toca, salvo el valor que requiere un interruptor que la búsqueda active (UseTakeProfit → TakeProfit): un valor para todas las estrategias" analytics.ticks.suggest_one_none: ru: "Выберите поле: щёлкните по его имени в таблице" en: "Pick a field: click its name in the grid" From 92463d2da92baefd5a731c52bd1a2950c9e7f20b Mon Sep 17 00:00:00 2001 From: guyverino Date: Fri, 25 Sep 2026 14:54:35 +0200 Subject: [PATCH 43/51] feat(tuner): model accuracy under the grid, the model's sell path in 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). --- crates/moon-chart/src/frozen_overlay.rs | 152 +++++++++----- crates/moon-chart/src/frozen_overlay/tests.rs | 70 +++++++ crates/moon-core/src/config/layout.rs | 9 + crates/moon-core/src/config/layout/tests.rs | 17 ++ crates/moon-core/src/db/tuner/ticks/search.rs | 19 +- .../src/db/tuner/ticks/search/tests.rs | 16 ++ .../src/analytics/tuner/ticks/accuracy.rs | 186 ++++++++++++++++++ .../analytics/tuner/ticks/accuracy/tests.rs | 57 ++++++ .../src/analytics/tuner/ticks/grid.rs | 20 +- .../src/analytics/tuner/ticks/load.rs | 1 + .../src/analytics/tuner/ticks/mod.rs | 1 + .../src/analytics/tuner/ticks/sections.rs | 23 ++- .../analytics/tuner/ticks/sections/tests.rs | 31 +++ .../src/analytics/tuner/ticks/state.rs | 9 + .../src/analytics/tuner/ticks/trade_pane.rs | 10 +- crates/moon-ui-gpui/src/trade_window/mod.rs | 47 ++++- .../moon-ui-gpui/src/trade_window/settings.rs | 14 ++ .../moon-ui-gpui/src/trade_window/window.rs | 12 +- locales/analytics.yml | 42 +++- locales/trade_window.yml | 7 + 20 files changed, 675 insertions(+), 68 deletions(-) create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/accuracy.rs create mode 100644 crates/moon-ui-gpui/src/analytics/tuner/ticks/accuracy/tests.rs diff --git a/crates/moon-chart/src/frozen_overlay.rs b/crates/moon-chart/src/frozen_overlay.rs index a27822a3..487b6860 100644 --- a/crates/moon-chart/src/frozen_overlay.rs +++ b/crates/moon-chart/src/frozen_overlay.rs @@ -44,6 +44,10 @@ pub struct OverlayTrade { pub fill_price: f32, /// Exit instant and price; `None` when the position was still open where the tape ends. pub exit: Option<(f64, f32)>, + /// The sell order's path as the model walked it — `(Unix UTC ms, level)` of each placement + /// from the fill, stepped to the exit like the entry path; empty draws the exit line flat at + /// the exit price, as a Moonbot line pair does. + pub exit_path: Vec<(f64, f32)>, /// Whether the position is short, which picks the short styles and the arrows' direction. pub is_short: bool, /// Pen of the entry path, the exit line and the connector (`SEG_PATTERN_*`) — what tells two @@ -135,47 +139,19 @@ pub fn build_overlay_geometry( } else { (&style.buy, &style.sell) }; - // The entry line the model walked, stepped like a repriced order's path, to the fill. + // The entry line the model walked, to the fill. let entry_color = rgba(entry_style.color, 1.0); - for (i, &(t_ms, level)) in trade.path.iter().enumerate() { - if !price_ok(level) || t_ms > trade.fill_ms { - continue; - } - let end_ms = trade - .path - .get(i + 1) - .map_or(trade.fill_ms, |(next_ms, _)| *next_ms) - .min(trade.fill_ms) - .max(t_ms); - segs.push(SegInstance { - t0_rel: to_rel(t_ms), - p0: level, - t1_rel: to_rel(end_ms), - p1: level, + push_stepped( + segs, + &trade.path, + Pen { + end_ms: trade.fill_ms, thickness: entry_style.thickness, pattern: trade.pattern, - extend: SEG_EXTEND_NONE, - clamp: SEG_CLAMP_NONE, color: entry_color, - }); - let riser = trade - .path - .get(i + 1) - .filter(|(next_ms, next)| *next_ms <= trade.fill_ms && price_ok(*next)); - if let Some(&(next_ms, next)) = riser { - segs.push(SegInstance { - t0_rel: to_rel(next_ms), - p0: level, - t1_rel: to_rel(next_ms), - p1: next, - thickness: 1.0, - pattern: trade.pattern, - extend: SEG_EXTEND_NONE, - clamp: SEG_CLAMP_NONE, - color: entry_color, - }); - } - } + epoch_ms, + }, + ); // Direction follows the ACTION, as trade history's arrows do: a long enters with a buy. markers.push(arrow( trade.fill_ms, @@ -187,19 +163,42 @@ pub fn build_overlay_geometry( continue; }; let exit_color = rgba(exit_style.color, 1.0); - // The exit line as a Moonbot line pair draws it: at the exit price, from the fill (where - // the exit order is placed) to the close. - segs.push(SegInstance { - t0_rel: to_rel(trade.fill_ms), - p0: exit_price, - t1_rel: to_rel(exit_ms.max(trade.fill_ms)), - p1: exit_price, - thickness: exit_style.thickness, - pattern: trade.pattern, - extend: SEG_EXTEND_NONE, - clamp: SEG_CLAMP_NONE, - color: exit_color, - }); + let exit_end_ms = exit_ms.max(trade.fill_ms); + // A path none of whose levels stood before the close — a stop inside `SellDelay`, before + // the sell was ever placed, or on the very ms it was — draws as a path without one would. + let path_drawn = trade + .exit_path + .iter() + .any(|&(t_ms, level)| t_ms < exit_end_ms && price_ok(level)); + if !path_drawn { + // The exit line as a Moonbot line pair draws it: at the exit price, from the fill + // (where the exit order is placed) to the close. + segs.push(SegInstance { + t0_rel: to_rel(trade.fill_ms), + p0: exit_price, + t1_rel: to_rel(exit_end_ms), + p1: exit_price, + thickness: exit_style.thickness, + pattern: trade.pattern, + extend: SEG_EXTEND_NONE, + clamp: SEG_CLAMP_NONE, + color: exit_color, + }); + } else { + // The sell order the model walked, from its placement to the close — a stop's exit + // lands off it, at the stop's own price. + push_stepped( + segs, + &trade.exit_path, + Pen { + end_ms: exit_end_ms, + thickness: exit_style.thickness, + pattern: trade.pattern, + color: exit_color, + epoch_ms, + }, + ); + } segs.push(SegInstance { t0_rel: to_rel(trade.fill_ms), p0: trade.fill_price, @@ -215,5 +214,58 @@ pub fn build_overlay_geometry( } } +/// How [`push_stepped`] draws one path. +struct Pen { + /// Nothing is drawn past this instant, Unix UTC ms. + end_ms: f64, + thickness: f32, + pattern: f32, + color: [f32; 4], + /// The pane's epoch; every instance is relative to it. + epoch_ms: f64, +} + +/// A path the model walked, stepped like a repriced order's: a level per placement to the next +/// one, a riser at each move, nothing past the pen's end. +fn push_stepped(segs: &mut Vec, path: &[(f64, f32)], pen: Pen) { + let to_rel = |t_ms: f64| (t_ms - pen.epoch_ms) as f32; + let price_ok = |p: f32| p.is_finite() && p > 0.0; + for (i, &(t_ms, level)) in path.iter().enumerate() { + if !price_ok(level) || t_ms > pen.end_ms { + continue; + } + let next = path.get(i + 1); + let to_ms = next + .map_or(pen.end_ms, |(next_ms, _)| *next_ms) + .min(pen.end_ms) + .max(t_ms); + segs.push(SegInstance { + t0_rel: to_rel(t_ms), + p0: level, + t1_rel: to_rel(to_ms), + p1: level, + thickness: pen.thickness, + pattern: pen.pattern, + extend: SEG_EXTEND_NONE, + clamp: SEG_CLAMP_NONE, + color: pen.color, + }); + let riser = next.filter(|(next_ms, next)| *next_ms <= pen.end_ms && price_ok(*next)); + if let Some(&(next_ms, next)) = riser { + segs.push(SegInstance { + t0_rel: to_rel(next_ms), + p0: level, + t1_rel: to_rel(next_ms), + p1: next, + thickness: 1.0, + pattern: pen.pattern, + extend: SEG_EXTEND_NONE, + clamp: SEG_CLAMP_NONE, + color: pen.color, + }); + } + } +} + #[cfg(test)] mod tests; diff --git a/crates/moon-chart/src/frozen_overlay/tests.rs b/crates/moon-chart/src/frozen_overlay/tests.rs index 05319cca..5005b6d1 100644 --- a/crates/moon-chart/src/frozen_overlay/tests.rs +++ b/crates/moon-chart/src/frozen_overlay/tests.rs @@ -73,6 +73,7 @@ fn a_modelled_trade_carries_its_pen_and_an_open_one_only_its_entry() { fill_ms: 3_000.0, fill_price: 1.0, exit: Some((6_000.0, 1.05)), + exit_path: Vec::new(), is_short: false, pattern: SEG_PATTERN_DASH, }, @@ -81,6 +82,7 @@ fn a_modelled_trade_carries_its_pen_and_an_open_one_only_its_entry() { fill_ms: 3_500.0, fill_price: 1.01, exit: None, + exit_path: Vec::new(), is_short: true, pattern: SEG_PATTERN_DOT, }, @@ -114,6 +116,7 @@ fn a_modelled_entry_path_steps_to_the_fill() { fill_ms: 3_000.0, fill_price: 0.99, exit: None, + exit_path: Vec::new(), is_short: false, pattern: SEG_PATTERN_DASH, }], @@ -135,3 +138,70 @@ fn a_modelled_entry_path_steps_to_the_fill() { "one riser: the second move falls after the fill" ); } + +/// The model's sell path is stepped from its placement to the close, like the entry path, and +/// stands in for the flat exit line; the exit arrow keeps the exit's own price. +#[test] +fn a_modelled_sell_path_steps_to_the_close() { + let (_, segs, markers) = build(&FrozenOverlay { + bands: Vec::new(), + trades: vec![OverlayTrade { + path: Vec::new(), + fill_ms: 2_000.0, + fill_price: 1.0, + exit: Some((5_000.0, 1.02)), + exit_path: vec![ + (2_000.0, 1.05), + (3_000.0, 1.03), + (4_000.0, 1.02), + (6_000.0, 1.01), + ], + is_short: false, + pattern: SEG_PATTERN_DASH, + }], + }); + assert_eq!(markers.len(), 2, "the entry and the exit arrow"); + let levels: Vec<(f32, f32, f32)> = segs + .iter() + .filter(|s| s.p0 == s.p1) + .map(|s| (s.t0_rel, s.t1_rel, s.p0)) + .collect(); + assert_eq!( + levels, + vec![ + (1_000.0, 2_000.0, 1.05), + (2_000.0, 3_000.0, 1.03), + (3_000.0, 4_000.0, 1.02), + ], + "no flat line at the exit price, and nothing past the close" + ); + assert_eq!( + segs.iter().filter(|s| s.p0 != s.p1).count(), + 3, + "two risers and the fill-to-exit connector" + ); +} + +/// A sell path none of whose levels stood by the close — a stop inside `SellDelay`, the sell not +/// yet placed — draws the flat exit line rather than nothing. +#[test] +fn a_sell_path_placed_after_the_close_falls_back_to_the_flat_line() { + let (_, segs, _) = build(&FrozenOverlay { + bands: Vec::new(), + trades: vec![OverlayTrade { + path: Vec::new(), + fill_ms: 2_000.0, + fill_price: 1.0, + exit: Some((3_000.0, 0.98)), + exit_path: vec![(4_000.0, 1.05)], + is_short: false, + pattern: SEG_PATTERN_DASH, + }], + }); + let levels: Vec<(f32, f32, f32)> = segs + .iter() + .filter(|s| s.p0 == s.p1) + .map(|s| (s.t0_rel, s.t1_rel, s.p0)) + .collect(); + assert_eq!(levels, vec![(1_000.0, 2_000.0, 0.98)]); +} diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index 9de42bf0..4e83fa64 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -857,6 +857,15 @@ pub struct WindowLayout { /// opened for. Apart from [`Self::trade_window_hide_rail`], which a window of its own keeps. #[serde(default, deserialize_with = "de_lenient")] pub analytics_trade_hide_rail: Option, + /// Print the trade's own captions — its strategy, the detect it fired on, why it closed — + /// at the top of trade windows; absent means ON. + #[serde(default, deserialize_with = "de_lenient")] + pub trade_window_labels: Option, + /// The same captions in the trade pane under the tuner's deal table; absent means OFF, as + /// the pane's rail is hidden — the pane is opened for the picture. Apart from + /// [`Self::trade_window_labels`], which a window of its own keeps. + #[serde(default, deserialize_with = "de_lenient")] + pub analytics_trade_labels: Option, /// Selected Profit Monitor period id. #[serde(default, deserialize_with = "de_lenient")] diff --git a/crates/moon-core/src/config/layout/tests.rs b/crates/moon-core/src/config/layout/tests.rs index e3994d96..c392e0ba 100644 --- a/crates/moon-core/src/config/layout/tests.rs +++ b/crates/moon-core/src/config/layout/tests.rs @@ -2230,6 +2230,23 @@ fn the_corridor_and_the_tuner_panes_rail_read_their_own_defaults() { assert_eq!(bad.trade_window_moonshot_zone, None); } +/// The trade captions read absent as ON in a window and OFF in the tuner pane, and each slot +/// survives a round trip without touching the other. +#[test] +fn the_trade_captions_read_their_own_defaults_per_host() { + let old: WindowLayout = toml::from_str("").expect("legacy layout"); + assert!(old.trade_window_labels.unwrap_or(true)); + assert!(!old.analytics_trade_labels.unwrap_or(false)); + let saved: WindowLayout = + toml::from_str("trade_window_labels = false\nanalytics_trade_labels = true").expect("set"); + let encoded = toml::to_string(&saved).expect("serialize preference"); + let reopened: WindowLayout = toml::from_str(&encoded).expect("reopen preference"); + assert_eq!(reopened.trade_window_labels, Some(false)); + assert_eq!(reopened.analytics_trade_labels, Some(true)); + let bad: WindowLayout = toml::from_str("analytics_trade_labels = \"on\"").expect("lenient"); + assert_eq!(bad.analytics_trade_labels, None); +} + /// `layout.rs:ChartGraphicsCfg::candle_volume_sides` — the serde default is OFF while `Default` /// is ON, on purpose: a file without the key predates the switch and its style alone said /// whether the band drew, so reading the key as ON there would open every user who had the band diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index 4ba77f1a..bd312d59 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -39,13 +39,14 @@ use std::sync::Arc; use rayon::prelude::*; +use super::exit::line::LinePoint; use super::mshot::{CorridorStep, EntryMethod, MshotEntry, MshotParams}; use super::params::range::Grids; use super::params::{ ParamGroup, ParamKind, StrategyValues, TICK_PARAMS, TickParam, exit_params, mshot_params, }; use super::settings::ModelSettings; -use super::{Deal, Deltas, EntryParams, ExitParams, Outcome, entry_model_for, simulate}; +use super::{Deal, Deltas, EntryParams, ExitModel, ExitParams, Outcome, entry_model_for, simulate}; use crate::db::metrics::Tally; use crate::db::tuner::threshold_search::search::{install, restart_seed}; use crate::db::tuner::threshold_search::{SearchHandle, train_split}; @@ -1109,6 +1110,9 @@ pub struct VariantPicture { /// replayed by the corridor model only; empty for a shift and for a kind without an entry /// model, which walk no corridor of their own. pub corridor: Vec, + /// The sell order's path as the exit model walked it from the fill — every level the line + /// stood at, placement first; empty when the entry never filled. + pub sell_line: Vec, } /// One variant replayed on ONE deal — what the tuner's trade pane draws beside the fact: where @@ -1157,7 +1161,18 @@ pub fn variant_picture( } _ => Vec::new(), }; - VariantPicture { outcome, corridor } + // The same walk `simulate` took its exit from — `ExitModel::exit` is this walk's `exit` — + // run again for its levels: one deal, once per pane refresh. + let sell_line = outcome.fill.map_or_else(Vec::new, |fill| { + ExitModel::new(&exit) + .walk(&deal.deal, &deal.ticks, fill) + .points + }); + VariantPicture { + outcome, + corridor, + sell_line, + } } /// Each deal's `(report_uid, (money, per cent))` under one variant, in the deals' order; `None` diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index c1296d00..b46abb82 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -180,6 +180,22 @@ fn the_search_raises_the_take_to_what_every_tape_reaches() { assert_eq!(exit.kind, crate::db::tuner::ticks::ExitKind::Take); assert!((exit.price - 101.0).abs() < 1e-6, "{exit:?}"); assert!((outcome.profit_pct.expect("a trade") - 1.0).abs() < 1e-6); + // The sell's path from the fill: placed no earlier than the fill, standing at the take the + // exit closed on when it closed. + let fill_ms = outcome.fill.expect("a fill").t_ms; + let first = picture.sell_line.first().expect("a sell line"); + assert!(first.t_ms >= fill_ms, "{:?}", picture.sell_line); + let last = picture + .sell_line + .iter() + .rev() + .find(|point| point.t_ms <= exit.t_ms) + .expect("a level at the close"); + assert!( + (last.price - exit.price).abs() < 1e-6, + "{:?}", + picture.sell_line + ); // And the deal table's plan column: every deal's money, summing to the column's tally. let (by_tally, by_spent, money) = variant_tally_by_deal( &deals, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/accuracy.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/accuracy.rs new file mode 100644 index 00000000..49b154cb --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/accuracy.rs @@ -0,0 +1,186 @@ +//! How far a search's answer can be trusted (LinKvo, 2026-09-25): under the parameter grid, once +//! a search has run, the share of the tape the model reproduces — "entry 85.5 %, exit 54.4 %" — +//! said plainly, because the search learns on the reproduced trades only and a reader who sees +//! its plan should know how much of the strategy's history stands behind it. +//! +//! The honest base is every trade of the scope whose tape the terminal holds: a trade the model +//! misses (✗) and one it cannot judge at all (·, a rule it has no model of) both count against it +//! — that is the part of the history the answer does not speak for. The ✓ shares of the KPI +//! caption answer a narrower question (of the trades the model judged, how many it matched) and +//! stay as they are. What the model assumes where the data is silent — no order book, one latency +//! for every core, the verdict's tolerances — goes into the tooltip beside the counts, with the +//! numbers the model runs under. + +use gpui::*; +use moon_core::db::tuner::ticks::{ModelSettings, Verdict}; +use moon_ui::MoonPalette; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +use crate::design; +use crate::design::moon; + +#[cfg(test)] +mod tests; + +/// One group's verdicts over the trades with tape. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(in crate::analytics::tuner) struct GroupCount { + /// Reproduced within the verdict's tolerances. + pub hits: usize, + /// Judged and missed. + pub misses: usize, + /// Not judged: a rule the model has no model of, or a close the model's line cannot be + /// compared with. + pub unjudged: usize, +} + +impl GroupCount { + fn add(&mut self, verdict: Option) { + match verdict { + Some(true) => self.hits += 1, + Some(false) => self.misses += 1, + None => self.unjudged += 1, + } + } + + /// Reproduced trades over every trade with tape, per cent; `None` with none. + pub fn pct(&self) -> Option { + let n = self.hits + self.misses + self.unjudged; + (n > 0).then(|| self.hits as f64 / n as f64 * 100.0) + } +} + +/// The model's accuracy over the scope's trades with tape. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(in crate::analytics::tuner) struct Accuracy { + pub entry: GroupCount, + pub exit: GroupCount, +} + +impl Accuracy { + /// Count the verdicts of the trades with tape. A trade with tape and no verdict yet is + /// unjudged in both groups — the tape is there, the model has not answered for it. + pub fn of<'a>(verdicts: impl IntoIterator>) -> Self { + let mut out = Self::default(); + for verdict in verdicts { + out.entry.add(verdict.and_then(|v| v.entry)); + out.exit.add(verdict.and_then(|v| v.exit)); + } + out + } + + /// Trades with tape the counts are over. + pub fn n(&self) -> usize { + self.exit.hits + self.exit.misses + self.exit.unjudged + } +} + +/// A share as the line prints it: one decimal, or a dash with nothing to count. +fn pct_text(pct: Option) -> String { + pct.map_or_else(|| "—".to_string(), |pct| format!("{pct:.1} %")) +} + +/// The tooltip: what each share counts, then what the model assumes, with its numbers. +fn tooltip(acc: &Accuracy, entry_modelled: bool, without_tape: usize, fit: usize) -> String { + let model = super::model_cfg::current(); + let group = |name: String, count: &GroupCount| { + t!( + "analytics.ticks.acc_tip_group", + name = name, + hits = count.hits, + misses = count.misses, + unjudged = count.unjudged + ) + .to_string() + }; + let mut lines = vec![t!("analytics.ticks.acc_tip_base", n = acc.n()).to_string()]; + if entry_modelled { + lines.push(group( + t!("analytics.ticks.group_entry").to_string(), + &acc.entry, + )); + } + lines.push(group( + t!("analytics.ticks.group_exit").to_string(), + &acc.exit, + )); + lines.push(t!("analytics.ticks.acc_tip_fit", fit = fit).to_string()); + if without_tape > 0 { + lines.push(t!("analytics.ticks.acc_tip_no_tape", n = without_tape).to_string()); + } + lines.push(String::new()); + lines.push(assumptions(&model)); + lines.join("\n") +} + +/// What the model assumes where the data is silent, with the numbers it runs under. +fn assumptions(model: &ModelSettings) -> String { + t!( + "analytics.ticks.acc_tip_assumptions", + latency = format!("{:.0}", model.latency_ms), + price = format!("{}", model.price_pct), + time = format!("{:.1}", model.point_time_ms as f64 / 1000.0), + ticker = format!("{:.1}", model.ticker_period_ms as f64 / 1000.0) + ) + .to_string() +} + +impl AnalyticsView { + /// The accuracy line under the parameter grid, once a search has run: the model's entry and + /// exit shares over the scope's trades with tape. Nothing before the first search, while + /// one runs, and with no trade with tape to count. + pub(super) fn ticks_accuracy_row(&self, p: MoonPalette, cx: &App) -> Option { + if self.ticks.last_result.is_none() + || matches!(self.ticks.sugg, super::state::SuggState::Running { .. }) + { + return None; + } + let data = self.ticks.data.data()?; + // Counted where the verdicts change (`TicksData::refresh_summary`), not per paint. + let acc = data.accuracy; + if acc.n() == 0 { + return None; + } + let entry_modelled = data.entry_modelled(); + let entry = if entry_modelled { + pct_text(acc.entry.pct()) + } else { + t!("analytics.ticks.acc_entry_fact").to_string() + }; + let text = t!( + "analytics.ticks.acc_line", + entry = entry, + exit = pct_text(acc.exit.pct()), + n = acc.n() + ) + .to_string(); + let without_tape = data.rows.len().saturating_sub(acc.n()); + let fit = data.fit(); + Some( + div() + .id("an-ticks-accuracy") + .w_full() + .flex_none() + .px(design::ui_px(cx, 12.0)) + .py(design::ui_px(cx, 4.0)) + .border_t_1() + .border_color(moon(p.border)) + .truncate() + .text_size(design::t_caption(cx)) + .font_family(design::ui_font()) + .text_color(moon(p.text_muted)) + // Built on hover only: the grid paints far more often than anyone reads it. + .tooltip(move |window, cx| { + crate::panels::common::text_tooltip(tooltip( + &acc, + entry_modelled, + without_tape, + fit, + ))(window, cx) + }) + .child(text) + .into_any_element(), + ) + } +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/accuracy/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/accuracy/tests.rs new file mode 100644 index 00000000..357449fd --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/accuracy/tests.rs @@ -0,0 +1,57 @@ +// Not `super::*`: the parent's `gpui::*` brings gpui's own `test` attribute, which `#[test]` would +// then name, and it expands into itself. +use super::{Accuracy, GroupCount, pct_text}; +use moon_core::db::tuner::ticks::Verdict; + +fn verdict(entry: Option, exit: Option) -> Verdict { + Verdict { + entry, + entry_dev_pct: None, + exit, + exit_dev_pct: None, + fill: None, + exit_kind: None, + line_points: None, + } +} + +/// A miss and a trade the model cannot judge both count against it: the share is over every +/// trade with tape, not over the judged ones the KPI caption counts. +#[test] +fn the_share_is_over_every_trade_with_tape() { + let verdicts = [ + verdict(Some(true), Some(true)), + verdict(Some(true), Some(false)), + verdict(Some(false), None), + verdict(Some(true), Some(true)), + ]; + let acc = Accuracy::of(verdicts.iter().map(Some).chain([None])); + assert_eq!(acc.n(), 5); + assert_eq!( + acc.entry, + GroupCount { + hits: 3, + misses: 1, + unjudged: 1 + } + ); + assert_eq!( + acc.exit, + GroupCount { + hits: 2, + misses: 1, + unjudged: 2 + } + ); + assert!((acc.entry.pct().unwrap() - 60.0).abs() < 1e-9); + assert!((acc.exit.pct().unwrap() - 40.0).abs() < 1e-9); +} + +#[test] +fn nothing_to_count_prints_a_dash() { + let acc = Accuracy::of(std::iter::empty()); + assert_eq!(acc.n(), 0); + assert_eq!(acc.exit.pct(), None); + assert_eq!(pct_text(None), "—"); + assert_eq!(pct_text(Some(85.54)), "85.5 %"); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index fa47d8a7..8a926b94 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -113,6 +113,9 @@ impl AnalyticsView { // the button would search is never hidden. let open = self.ticks.open_sections.contains(§ion.section); let sel = self.ticks.sel_field; + // The knobs filed among the other group's are marked, so a search of one group does + // not read as leaving the whole section alone. + let odd = section.minority_group(); for row in section .rows .iter() @@ -121,7 +124,8 @@ impl AnalyticsView { let now = data.as_ref().and_then(|d| d.now.get(&row.key).cloned()); grid = grid.child(match row.role { RowRole::Knob(knob) if knob_live(knob, entry_on) => { - self.ticks_field_row(knob.key, now, p, window, cx) + let odd = odd.filter(|group| *group == knob.group); + self.ticks_field_row(knob.key, odd, now, p, window, cx) } role => self.ticks_fixed_row(&row.key, role, now, data.as_deref(), p, cx), }); @@ -131,6 +135,9 @@ impl AnalyticsView { // priced again whenever what a point costs under may have moved (`estimate.rs`). self.ticks_measure_cost(cx); let estimate = self.ticks_estimate_row(p, cx); + // After a search: how much of the scope's tape the model reproduces — the part of the + // strategy's history the answer speaks for (`accuracy.rs`). + let accuracy = self.ticks_accuracy_row(p, cx); v_flex() .w_full() .flex_1() @@ -152,6 +159,7 @@ impl AnalyticsView { .child(grid), ) .children(estimate) + .children(accuracy) .into_any_element() } @@ -555,6 +563,7 @@ impl AnalyticsView { fn ticks_field_row( &mut self, key: &'static str, + odd: Option, now: Option, p: MoonPalette, window: &mut Window, @@ -613,9 +622,18 @@ impl AnalyticsView { moon(p.amber) } else if !read { moon(p.text_muted) + } else if odd.is_some() { + moon(p.blue) } else { moon(p.text) }) + .when_some(odd, |el, group| { + let key = match group { + ParamGroup::Entry => "analytics.ticks.row_odd_entry", + ParamGroup::Exit => "analytics.ticks.row_odd_exit", + }; + el.tooltip(crate::panels::common::text_tooltip(t!(key).to_string())) + }) .child(key) .on_click(cx.listener(move |this, _, _, cx| { this.ticks.sel_field = Some(key); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index a19c00ba..fae69246 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -359,6 +359,7 @@ impl AnalyticsView { kpi: Vec::new(), entry_share: (0, 0), exit_share: (0, 0), + accuracy: Default::default(), kinds, now: scope.now, own: scope.own, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs index 1edd4943..20f920c4 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -35,6 +35,7 @@ pub(in crate::analytics::tuner) use fetch::strategy_field_defaults; use moon_core::market::trade_replay::TickStatus; use state::{DealRow, TapeStatus}; +mod accuracy; mod cfg; pub(in crate::analytics::tuner) mod columns; mod delta_summary; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs index 211e3254..c7248807 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs @@ -18,7 +18,9 @@ use std::collections::{HashMap, HashSet}; use moon_core::db::tuner::ticks::Deal; -use moon_core::db::tuner::ticks::params::{ParamSection, TickParam, is_model_only, params_for}; +use moon_core::db::tuner::ticks::params::{ + ParamGroup, ParamSection, TickParam, is_model_only, params_for, +}; use moon_core::db::tuner::ticks::unmodelled::fields_in_use; use moon_core::feed::SchemaSection; use moon_core::feed::strategy_deps::FieldDeps; @@ -67,6 +69,25 @@ impl GridSection { _ => None, }) } + + /// The search group whose knobs are the fewer in a section that holds both — a field the + /// strategy editor files among the other group's, as `MShotSellPriceAdjust` (an exit field) + /// sits in Strategy settings among the entry's corridor. The grid marks those, so a search + /// of one group is not read as leaving the whole section alone. `None` for a section of one + /// group, and for an even split, where neither side is the odd one out. + pub(in crate::analytics::tuner) fn minority_group(&self) -> Option { + let entry = self + .knobs() + .filter(|k| k.group == ParamGroup::Entry) + .count(); + let exit = self.knobs().filter(|k| k.group == ParamGroup::Exit).count(); + match (entry, exit) { + (0, _) | (_, 0) => None, + (entry, exit) if entry < exit => Some(ParamGroup::Entry), + (entry, exit) if exit < entry => Some(ParamGroup::Exit), + _ => None, + } + } } /// The knobs the scope's kinds understand — the union over the kinds present, in descriptor diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs index 70bc6ff0..b42db909 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs @@ -252,3 +252,34 @@ fn a_strategy_id_past_i64_max_is_the_reports_negative_one() { assert!(same_strategy(42, 42)); assert!(!same_strategy(42, 43)); } + +/// A section holding knobs of both groups marks the fewer: MoonShot's Strategy settings carry the +/// entry corridor and two exit fields; a section of one group, or split evenly, marks none. +#[test] +fn the_odd_group_of_a_mixed_section_is_the_fewer() { + let knobs = scope_knobs(&["MoonShot".to_string()]); + let out = layout(&[], &knobs, &HashSet::new()); + let strategy = find(&out, ParamSection::StrategySettings); + assert!(keys(strategy).contains(&"MShotSellPriceAdjust")); + assert!(keys(strategy).contains(&"MShotPrice")); + assert_eq!(strategy.minority_group(), Some(ParamGroup::Exit)); + assert_eq!(find(&out, ParamSection::Stops).minority_group(), None); + // An even split has no odd one out. + let even = GridSection { + section: ParamSection::StrategySettings, + rows: ["MShotPrice", "MShotSellPriceAdjust"] + .iter() + .map(|key| { + let param = moon_core::db::tuner::ticks::TICK_PARAMS + .iter() + .find(|p| p.key == *key) + .expect("a knob"); + GridRow { + key: key.to_string(), + role: RowRole::Knob(param), + } + }) + .collect(), + }; + assert_eq!(even.minority_group(), None); +} diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index 0498bf03..c1a2a87c 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -165,6 +165,9 @@ pub(in crate::analytics::tuner) struct TicksData { pub(in crate::analytics::tuner) entry_share: (usize, usize), /// `(hits, answered)` of the exit group over the covered rows. pub(in crate::analytics::tuner) exit_share: (usize, usize), + /// The model's accuracy over every covered row — unjudged ones counted against it — for + /// the line under the grid after a search (`accuracy.rs`). + pub(in crate::analytics::tuner) accuracy: super::accuracy::Accuracy, /// Strategy kinds present among the rows, for the entry group's availability. pub(in crate::analytics::tuner) kinds: Vec, /// The parameter grid's "now" column, by field key. @@ -842,5 +845,11 @@ impl TicksData { let verdicts = || self.rows.iter().filter_map(|r| r.verdict.as_ref()); self.entry_share = moon_core::db::tuner::ticks::verify::share(verdicts().map(|v| v.entry)); self.exit_share = moon_core::db::tuner::ticks::verify::share(verdicts().map(|v| v.exit)); + self.accuracy = super::accuracy::Accuracy::of( + self.rows + .iter() + .filter(|r| r.tape == TapeStatus::Covered) + .map(|r| r.verdict.as_ref()), + ); } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs index 67e4ddbe..5c300691 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs @@ -1,8 +1,9 @@ //! The trade pane under the deal table: the selected deal drawn as its trade window draws it — //! the same view (`trade_window::Host::Embedded`), not a second chart — with the trades the //! variant column would have made beside the fact, dashed: the path the variant's entry order -//! walked, its fill and exit, and — under the window's MoonShot zone switch — the corridor the -//! model held around that order, placement by placement. +//! walked, its fill, the path its sell order walked to the exit, and — under the window's +//! MoonShot zone switch — the corridor the model held around the entry order, placement by +//! placement. //! //! Folded by default, behind a rail like the one between the halves of the tab; folded, a click //! on a row selects nothing and nothing is built. Open, a click on a row shows that deal; a @@ -201,6 +202,11 @@ impl AnalyticsView { .exit .filter(|exit| exit.kind != ExitKind::OpenAtWindowEnd) .map(|exit| (exit.t_ms as f64, exit.price as f32)), + exit_path: picture + .sell_line + .iter() + .map(|point| (point.t_ms as f64, point.price as f32)) + .collect(), is_short, pattern: VARIANT_PATTERN, }) diff --git a/crates/moon-ui-gpui/src/trade_window/mod.rs b/crates/moon-ui-gpui/src/trade_window/mod.rs index b1ad9704..e17c7ed4 100644 --- a/crates/moon-ui-gpui/src/trade_window/mod.rs +++ b/crates/moon-ui-gpui/src/trade_window/mod.rs @@ -383,6 +383,8 @@ pub(crate) struct TradeWindowView { fit_trade: bool, /// Hide the figures rail, leaving the chart the whole window. hide_rail: bool, + /// Print the trade's own captions — strategy, detect, sell reason — at the top of the chart. + show_labels: bool, /// Shade the entry corridor the core saved, from the order's placement to its fill. show_corridor: bool, /// Trades a model says a variant of the strategy would have made — the tuner's pane hands @@ -576,6 +578,32 @@ impl TradeWindowView { cx.notify(); } + /// Print or drop the trade's own captions, and remember it — a pane in its own slot, as its + /// rail. Off hands the chart no trade to caption, and the three trade fields print nothing + /// (`chartdx::text::labels`); on names the strategy afresh, as the window's first paint does. + fn set_show_labels(&mut self, show: bool, cx: &mut Context) { + if self.show_labels == show { + return; + } + self.show_labels = show; + let embedded = self.host.embedded(); + let labels = self.backend.update(cx, |backend, _| { + let slot = match embedded { + true => &mut backend.layout.analytics_trade_labels, + false => &mut backend.layout.trade_window_labels, + }; + if *slot != Some(show) { + *slot = Some(show); + backend.layout_dirty = true; + } + show.then(|| std::rc::Rc::new(trade_labels(backend, self.core, &self.meta).0)) + }); + self.panel.update(cx, |panel, pcx| { + panel.attach_trade_labels(labels, pcx); + }); + cx.notify(); + } + /// Shade the entry corridor or stop, and remember it for every trade view. fn set_show_corridor(&mut self, show: bool, cx: &mut Context) { if self.show_corridor == show { @@ -750,14 +778,17 @@ impl TradeWindowView { return; }; self.strategy_pending = false; - let labels = std::rc::Rc::new(crate::chartdx::TradeLabels { - strategy: name, - detect: self.meta.detect.clone(), - sell_reason: self.meta.sell_reason.clone(), - }); - self.panel.update(cx, |panel, pcx| { - panel.attach_trade_labels(Some(labels), pcx); - }); + // Switched off, the name waits in the store: the switch builds the captions afresh. + if self.show_labels { + let labels = std::rc::Rc::new(crate::chartdx::TradeLabels { + strategy: name, + detect: self.meta.detect.clone(), + sell_reason: self.meta.sell_reason.clone(), + }); + self.panel.update(cx, |panel, pcx| { + panel.attach_trade_labels(Some(labels), pcx); + }); + } // The rail's strategy block reads `strategy_pending` on render. This view's repaint must // not depend on the panel's own notify above reaching the window: state of THIS view // changed, so THIS view says so. diff --git a/crates/moon-ui-gpui/src/trade_window/settings.rs b/crates/moon-ui-gpui/src/trade_window/settings.rs index e005b5a2..21092c1d 100644 --- a/crates/moon-ui-gpui/src/trade_window/settings.rs +++ b/crates/moon-ui-gpui/src/trade_window/settings.rs @@ -276,6 +276,7 @@ impl TradeWindowView { show_other_trades: self.show_other_trades, fit_trade: self.fit_trade, hide_rail: self.hide_rail, + show_labels: self.show_labels, load_ticks: self.load_ticks, show_corridor: self.show_corridor, }, @@ -306,6 +307,7 @@ struct WindowSwitches { show_other_trades: bool, fit_trade: bool, hide_rail: bool, + show_labels: bool, load_ticks: bool, show_corridor: bool, } @@ -373,6 +375,17 @@ fn render_settings_popup( }) }; + let labels_cb = { + let entity = entity.clone(); + MoonCheckbox::new("trade-window-labels") + .label(t!("trade_window.settings.show_labels").to_string()) + .checked(switches.show_labels) + .on_change(move |show: &bool, _w, app| { + let show = *show; + entity.update(app, |this, cx| this.set_show_labels(show, cx)); + }) + }; + // --- Volumes: the band, ONE switch as on the main chart, written as the pair the rule maps // it to. The replay serves it from its own prints and bars; the per-trade bars the band // replaces are switched off by the band itself (`chartdx::data_state::market`). --- @@ -573,6 +586,7 @@ fn render_settings_popup( .child(fit_cb) .child(ticks_cb) .child(corridor_cb) + .child(labels_cb) .child(hide_rail_cb), ), ) diff --git a/crates/moon-ui-gpui/src/trade_window/window.rs b/crates/moon-ui-gpui/src/trade_window/window.rs index a3c9d8ca..15dc5441 100644 --- a/crates/moon-ui-gpui/src/trade_window/window.rs +++ b/crates/moon-ui-gpui/src/trade_window/window.rs @@ -229,6 +229,15 @@ fn new_view( // keeps asking until it does. let (resolved, named) = super::trade_labels(owner.read(vcx), core, &meta); let labels = std::rc::Rc::new(resolved); + // Whether they are printed at all, per host like the rail: a window prints them unless told + // not to, the tuner's pane only when told to. + let show_labels = { + let layout = &owner.read(vcx).layout; + match host.embedded() { + true => layout.analytics_trade_labels.unwrap_or(false), + false => layout.trade_window_labels.unwrap_or(true), + } + }; // The entry instant on the terminal's clock, for the strategy-version lookup: `fetch` resolves // the same pair again for the REST window, but that one is re-resolved on every Retry and the // version placement has no reason to follow it. @@ -304,7 +313,7 @@ fn new_view( // timeframe pin above: they come from the replica, not from the network, so the window // states what this trade WAS even while the picture behind it is still loading — and // never has to swap one set of captions for another once it lands. - panel.attach_trade_labels(Some(labels.clone()), pcx); + panel.attach_trade_labels(show_labels.then(|| labels.clone()), pcx); if let Some(pct) = saved_scale { panel.force_scale(Some(pct), pcx); } @@ -344,6 +353,7 @@ fn new_view( settings_open: false, fit_trade, hide_rail, + show_labels, show_corridor, model_trades: Vec::new(), model_corridor: Vec::new(), diff --git a/locales/analytics.yml b/locales/analytics.yml index 20cd603a..3439b042 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1777,9 +1777,9 @@ analytics.ticks.trade_collapse: en: "Collapse the trade pane" es: "Contraer el panel de la operación" analytics.ticks.trade_legend: - ru: "факт — сплошные, В1 — штрих, В2 — пунктир" - en: "fact solid, V1 dashed, V2 dotted" - es: "real continuo, V1 a trazos, V2 punteado" + ru: "факт — сплошные, В1 — штрих: путь бай- и селл-ордера модели" + en: "fact solid, V1 dashed: the model's buy and sell order paths" + es: "real continuo, V1 a trazos: recorrido de las órdenes de compra y venta del modelo" analytics.ticks.trade_pick: ru: "Выберите сделку в таблице" en: "Pick a trade in the table" @@ -1944,6 +1944,14 @@ analytics.ticks.section_expand: ru: "Развернуть секцию" en: "Open the section" es: "Abrir la sección" +analytics.ticks.row_odd_exit: + ru: "Относится к выходу, хотя стоит среди полей входа — подбирается вместе с группой «Выход»" + en: "Belongs to the exit, though filed among the entry fields — searched with the Exit group" + es: "Pertenece a la salida aunque figura entre los campos de entrada — se busca con el grupo Salida" +analytics.ticks.row_odd_entry: + ru: "Относится к входу, хотя стоит среди полей выхода — подбирается вместе с группой «Вход»" + en: "Belongs to the entry, though filed among the exit fields — searched with the Entry group" + es: "Pertenece a la entrada aunque figura entre los campos de salida — se busca con el grupo Entrada" analytics.ticks.row_fixed: ru: "Модель берёт значение стратегии — в переборе не участвует" en: "The model reads the strategy's value — it is not searched" @@ -2056,6 +2064,34 @@ analytics.ticks.est_tip: ru: "Оценка по отмеченным полям, сеткам, перезапускам и проходам. Отмечены и вход, и выход — на каждую точку входа подбирается весь выход, время растёт кратно. Время — по замеру одной точки на этой выборке, после каждого подбора уточняется. Порядок, не точное число." en: "An estimate from the ticked fields, the grids, restarts and passes. With both the entry and the exit ticked, the whole exit is searched under every entry point, and the time multiplies. The time is one point measured on this sample, refined after every search. An order of magnitude, not an exact figure." es: "Estimación a partir de los campos marcados, las rejillas, los reinicios y las pasadas. Con la entrada y la salida marcadas, se busca toda la salida en cada punto de entrada y el tiempo se multiplica. El tiempo sale de medir un punto en esta muestra y se afina tras cada búsqueda. Un orden de magnitud, no una cifra exacta." +analytics.ticks.acc_line: + ru: "Точность модели: вход %{entry} · выход %{exit} — по %{n} сделкам с лентой" + en: "Model accuracy: entry %{entry} · exit %{exit} — over %{n} trades with tape" + es: "Precisión del modelo: entrada %{entry} · salida %{exit} — sobre %{n} operaciones con cinta" +analytics.ticks.acc_entry_fact: + ru: "по факту" + en: "from the fact" + es: "del hecho" +analytics.ticks.acc_tip_base: + ru: "Доля сделок с лентой (%{n}), которые модель повторила по своим правилам. Промах ✗ и «не судится» · считаются против модели: за эти сделки подбор не говорит." + en: "The share of the trades with tape (%{n}) the model repeated by its own rules. A miss ✗ and a trade it cannot judge · both count against it: the search does not speak for those trades." + es: "La parte de las operaciones con cinta (%{n}) que el modelo repitió con sus propias reglas. Un fallo ✗ y una operación que no puede juzgar · cuentan en su contra: la búsqueda no habla por ellas." +analytics.ticks.acc_tip_group: + ru: "%{name}: ✓ %{hits} · ✗ %{misses} · не судится %{unjudged}" + en: "%{name}: ✓ %{hits} · ✗ %{misses} · not judged %{unjudged}" + es: "%{name}: ✓ %{hits} · ✗ %{misses} · sin juzgar %{unjudged}" +analytics.ticks.acc_tip_fit: + ru: "Подбор учился на %{fit} годных; на остальных его результат — экстраполяция." + en: "The search learned on %{fit} fit trades; on the rest its answer is an extrapolation." + es: "La búsqueda aprendió con %{fit} operaciones aptas; en el resto su respuesta es una extrapolación." +analytics.ticks.acc_tip_no_tape: + ru: "Без ленты — %{n}: в оценку не входят." + en: "Without tape: %{n}, left out of the estimate." + es: "Sin cinta: %{n}, fuera de la estimación." +analytics.ticks.acc_tip_assumptions: + ru: "Допущения модели: стакана нет — очередь перед нашим ордером не моделируется, филл = касание принтом; стоп по стакану видит цену тикера раз в %{ticker} с, вместо неё — последний принт; задержка ядра и биржи — одна на все ядра, %{latency} мс; совпадением считается цена в пределах %{price} % и момент в пределах %{time} с; поля выхода вне модели (SellShot, SellSpread и другие) — такие сделки не судятся." + en: "The model assumes: no order book — the queue ahead of our order is not modelled, a fill is a touching print; a book-watching stop sees the ticker price every %{ticker} s, the last print standing in for it; one core and venue latency for every core, %{latency} ms; a match is a price within %{price} % and a moment within %{time} s; exit fields outside the model (SellShot, SellSpread and others) leave such trades unjudged." + es: "El modelo supone: sin libro de órdenes — la cola delante de nuestra orden no se modela, un llenado es un print que toca; un stop por libro ve el precio del ticker cada %{ticker} s, con el último print en su lugar; una latencia de núcleo y bolsa para todos los núcleos, %{latency} ms; coincide un precio dentro de %{price} % y un momento dentro de %{time} s; los campos de salida fuera del modelo (SellShot, SellSpread y otros) dejan esas operaciones sin juzgar." analytics.ticks.long_title: ru: "Долгий подбор" en: "A long search" diff --git a/locales/trade_window.yml b/locales/trade_window.yml index dd889733..36ec7840 100644 --- a/locales/trade_window.yml +++ b/locales/trade_window.yml @@ -45,6 +45,13 @@ trade_window.settings.fit: en: "Fit the trade to the window" es: "Ajustar la operación a la ventana" +# The trade's own captions at the top of the chart: its strategy, the detect it fired on, why it +# closed. On in a trade window, off in the tuner's pane — each remembers its own. +trade_window.settings.show_labels: + ru: "Надписи сделки на чарте" + en: "Trade captions on the chart" + es: "Rótulos de la operación en el gráfico" + trade_window.settings.hide_rail: ru: "Скрыть правую панель" en: "Hide the side panel" From 58911f04e157b4cae49281ecd656e599d779a14d Mon Sep 17 00:00:00 2001 From: guyverino Date: Fri, 25 Sep 2026 17:27:57 +0200 Subject: [PATCH 44/51] feat(gap): introduce TapeGap to handle holes in long positions - 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. --- .../src/db/tuner/ticks/calibrate/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/deals.rs | 1 + .../moon-core/src/db/tuner/ticks/exit/line.rs | 195 ++++++++-- .../src/db/tuner/ticks/exit/stops.rs | 16 + .../src/db/tuner/ticks/exit/stops/ladder.rs | 5 + .../src/db/tuner/ticks/exit/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/gap.rs | 211 +++++++++++ .../moon-core/src/db/tuner/ticks/gap/tests.rs | 353 ++++++++++++++++++ crates/moon-core/src/db/tuner/ticks/mod.rs | 42 ++- crates/moon-core/src/db/tuner/ticks/record.rs | 17 +- .../src/db/tuner/ticks/record/tests.rs | 10 +- .../src/db/tuner/ticks/search/tests.rs | 1 + .../src/db/tuner/ticks/stats/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/tests.rs | 1 + .../src/db/tuner/ticks/tests/real_data.rs | 7 + crates/moon-core/src/db/tuner/ticks/verify.rs | 9 +- .../tuner/ticks/delta_summary/tests.rs | 1 + .../src/analytics/tuner/ticks/load.rs | 1 + .../src/analytics/tuner/ticks/rows/tests.rs | 1 + .../src/analytics/tuner/ticks/trade_pane.rs | 4 +- locales/analytics.yml | 12 +- locales/storage.yml | 12 +- 22 files changed, 854 insertions(+), 48 deletions(-) create mode 100644 crates/moon-core/src/db/tuner/ticks/gap.rs create mode 100644 crates/moon-core/src/db/tuner/ticks/gap/tests.rs diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs index c8c23b33..92b484ea 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs @@ -34,6 +34,7 @@ fn deal() -> Deal { buy_set_ms: None, corridor: None, entry_placed: None, + gap: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index e71fe23a..66d921ed 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -227,6 +227,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { archived_take: None, // Filled with the model inputs, once the archive is in (`record::prepare_deal`). entry_placed: None, + gap: None, // Filled by `overlay_hook_detect` off the raw report row's comment. hook_depth_pct: None, hook_stated_take_pct: None, diff --git a/crates/moon-core/src/db/tuner/ticks/exit/line.rs b/crates/moon-core/src/db/tuner/ticks/exit/line.rs index 8c9f26de..047f653a 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/line.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/line.rs @@ -19,6 +19,10 @@ use super::pump_move::PumpMove; use super::sell_order::{PriceDown, SellLevel, armed_at}; use super::stops::Stops; use super::{ExitParams, Side}; +use crate::db::tuner::ticks::gap::{ + HOLE_PRICE_TOLERANCE, HOLE_TIME_SLACK_MS, TapeGap, fact_level_near, sell_not_nearer, + stop_not_nearer, trigger_not_quicker, +}; use crate::db::tuner::ticks::{Deal, Exit, ExitKind, Fill, reaches, round_to_step}; use crate::feed::types::Tick; @@ -172,6 +176,16 @@ impl Line { }) } + /// The level the exchange held at `t_ms` — the last level sent that reached it by then; + /// `None` before the take was placed. + fn level_at(&self, t_ms: i64) -> Option { + self.points + .iter() + .rev() + .find(|p| p.t_ms <= t_ms) + .map(|p| p.price) + } + fn close(self, exit: Exit) -> LineWalk { LineWalk { exit, @@ -218,6 +232,8 @@ pub fn walk_held( let mut sell_level = SellLevel::new(params, deal, fill, side); let mut stops = Stops::new(deal, ticks, fill, params, side); + // A long position's hole, while the walk has yet to cross it. + let mut hole = deal.gap.as_ref().filter(|gap| gap.to_ms > fill.t_ms); let mut last_t = fill.t_ms; for (index, tick) in ticks.iter().enumerate() { let t_ms = tick.time_ms as i64; @@ -232,32 +248,47 @@ pub fn walk_held( if let Some(exit) = stops.fired_by(t_ms) { return finish(line, exit, &stops); } + // The first print past a hole is not the market's next print: the rules ran through + // hours nobody holds. Everything due before the hole ran on the prints before it; what + // the hole itself did is judged against the fact (`cross_hole`), never sold on here. + if let Some(gap) = hole.filter(|gap| t_ms > gap.from_ms.max(fill.t_ms)) { + hole = None; + let from_ms = gap.from_ms.max(fill.t_ms); + let rules = Rules { + pump_move: &mut pump_move, + price_down: &mut price_down, + sell_level: &mut sell_level, + }; + rules.step_through(from_ms, &ticks[..index], &mut line); + line.land(from_ms); + let held_through = hold_until_ms.is_some_and(|until| until >= gap.to_ms); + let rules = Rules { + pump_move: &mut pump_move, + price_down: &mut price_down, + sell_level: &mut sell_level, + }; + if let Some(exit) = cross_hole( + gap, + from_ms, + rules, + &mut line, + &stops, + params, + side, + held_through, + ) { + return finish(line, exit, &stops); + } + } // The timer-driven rules moved the line at their own moments, between prints; every // step due by this print happened BEFORE it, and a step that also reached the book // before it is what this print meets. - // - // PriceDown steps, the pump move and SellLevel's moves, one per due moment, in the - // order they fell due — on a tie the pump move, then PriceDown, then SellLevel: each step - // chains off where the one before it left the line. (SellLevel ran as a pass of its own - // after the others until 2026-09-24, so a PriceDown step due after a SellLevel move went - // first, off the level the move then replaced.) - loop { - let due = [ - pump_move.due(t_ms), - price_down.due(t_ms), - sell_level.due(t_ms), - ] - .into_iter() - .enumerate() - .filter_map(|(rule, due)| due.map(|due| (due, rule))) - .min(); - match due { - None => break, - Some((due, 0)) => pump_move.step(due, seen, &mut line), - Some((due, 1)) => price_down.step(due, &mut line), - Some((due, _)) => sell_level.step(due, seen, &mut line), - } - } + let rules = Rules { + pump_move: &mut pump_move, + price_down: &mut price_down, + sell_level: &mut sell_level, + }; + rules.step_through(t_ms, seen, &mut line); line.land(t_ms); // The stops come before the print-driven rule below moves anything. if let Some(exit) = stops.on_print(tick, t_ms, price) { @@ -299,6 +330,124 @@ pub fn walk_held( ) } +/// The rules that move the sell line on their own clocks. +struct Rules<'r, 'a> { + pump_move: &'r mut PumpMove<'a>, + price_down: &'r mut PriceDown<'a>, + sell_level: &'r mut SellLevel<'a>, +} + +impl Rules<'_, '_> { + /// Every step due by `t_ms`: PriceDown steps, the pump move and SellLevel's moves, one per + /// due moment, in the order they fell due — on a tie the pump move, then PriceDown, then + /// SellLevel: each step chains off where the one before it left the line. (SellLevel ran as + /// a pass of its own after the others until 2026-09-24, so a PriceDown step due after a + /// SellLevel move went first, off the level the move then replaced.) + /// + /// Args: + /// seen: The prints up to `t_ms` — what the price-following rules read. + fn step_through(self, t_ms: i64, seen: &[Tick], line: &mut Line) { + loop { + let due = [ + self.pump_move.due(t_ms), + self.price_down.due(t_ms), + self.sell_level.due(t_ms), + ] + .into_iter() + .enumerate() + .filter_map(|(rule, due)| due.map(|due| (due, rule))) + .min(); + match due { + None => break, + Some((due, 0)) => self.pump_move.step(due, seen, line), + Some((due, 1)) => self.price_down.step(due, line), + Some((due, _)) => self.sell_level.step(due, seen, line), + } + } + } +} + +/// Carry the walk across a long position's hole ([`TapeGap`]), from `from_ms` — its start, or the +/// fill when the take came after it — to its end, the PriceDown steps it holds taken on their +/// timer, which needs no print. `None` when the variant cannot have closed inside the hole; the +/// exit [`ExitKind::InGap`] when it may have. +/// +/// It may have when a rule of it follows the price through the hole (SellLevel or the pump move +/// still to come, the trailing stop, a ladder rung still to take — their levels are functions of +/// prints nobody holds); when its stop stands nearer the price than the fact's, or fires on a +/// quicker trigger, and the fact does not prove the variant's own stop quiet; and when its sell +/// stood nearer the price than the core's line at some moment of the hole, or the core's line is +/// not on record for that moment. The core's own line and stop were not reached there, or the +/// trade would have closed inside it. +/// +/// Args: +/// held_through: The walk holds the sell past the hole — the verdict judging where the line +/// stood at the close: no print fills the sell there, so its comparison proves nothing +/// the verdict needs. +#[allow(clippy::too_many_arguments)] +fn cross_hole( + gap: &TapeGap, + from_ms: i64, + rules: Rules<'_, '_>, + line: &mut Line, + stops: &Stops, + params: &ExitParams, + side: Side, + held_through: bool, +) -> Option { + let in_gap = Some(Exit { + t_ms: from_ms, + price: f64::NAN, + kind: ExitKind::InGap, + }); + if rules.pump_move.due(i64::MAX).is_some() + || rules.sell_level.due(i64::MAX).is_some() + || stops.follows_price() + { + return in_gap; + } + if let Some(level) = stops.level().filter(|_| stops.quiet_until() < gap.to_ms) { + let bounded = gap.fact_stop.is_some_and(|fact| { + stop_not_nearer(level, fact.level, side.long) + && trigger_not_quicker(params.fast_stop_loss, params.stop_loss_ema, &fact) + }); + if !bounded { + return in_gap; + } + } + while let Some(due) = rules.price_down.due(gap.to_ms) { + rules.price_down.step(due, line); + } + line.land(gap.to_ms); + if held_through { + return None; + } + // Every moment either line moved — the core's within the slack either side — and the hole's + // start: both are steps, so the comparison at each is the comparison throughout. Within the + // verdict's default tolerances the two lines are one line (`gap::fact_level_near`). + let fact_points = gap.fact_line.as_deref().unwrap_or_default(); + let slack_ms = HOLE_TIME_SLACK_MS; + let price_tolerance = HOLE_PRICE_TOLERANCE; + let mut moments: Vec = std::iter::once(from_ms) + .chain(line.points.iter().map(|p| p.t_ms)) + .chain( + fact_points + .iter() + .flat_map(|(t, _)| [t.saturating_sub(slack_ms), *t, t.saturating_add(slack_ms)]), + ) + .filter(|t| (from_ms..=gap.to_ms).contains(t)) + .collect(); + moments.sort_unstable(); + moments.dedup(); + let reached = moments.into_iter().any(|t| match line.level_at(t) { + // No sell stood yet (`SellDelay`): nothing to reach. + None => false, + Some(variant) => fact_level_near(fact_points, t, slack_ms, side.long) + .is_none_or(|fact| !sell_not_nearer(variant, fact, side.long, price_tolerance)), + }); + if reached { in_gap } else { None } +} + /// Close the line on `exit`, with the stop's level as it then stood. fn finish(line: Line, exit: Exit, stops: &Stops) -> LineWalk { let mut walked = line.close(exit); diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops.rs index 8323bc7c..e647c7f3 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/stops.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops.rs @@ -429,6 +429,22 @@ impl Stops { } } + /// Up to when the fact proves this stop quiet (`record::StopAnchor`): `i64::MIN` when the walk + /// is not the trade's own stop, or there is none. + pub(super) fn quiet_until(&self) -> i64 { + match &self.trigger { + Trigger::Off => i64::MIN, + Trigger::Fast { quiet_until, .. } => *quiet_until, + Trigger::Book(book) => book.quiet_until, + } + } + + /// Whether a rule of the stop moves with the price — the trailing stop, or a ladder rung + /// still to take: where it stands after a stretch of time is a function of the prints in it. + pub(super) fn follows_price(&self) -> bool { + self.trailing.is_some() || self.ladder.as_ref().is_some_and(Ladder::pending) + } + /// The ladder's steps due before `until`, moving the stop's level when one is taken. The step /// is read on the ticker's arrivals before the print, and the stop's own arrivals up to it /// then read the new level — within one gap between prints, the order of the two is not diff --git a/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs index fb9703d1..b5f9ff56 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs @@ -144,6 +144,11 @@ impl Ladder { moved } + /// Whether a step is still to be taken. + pub(super) fn pending(&self) -> bool { + self.rungs.iter().any(|r| !r.taken) + } + /// Read a print: a taker sell stands for the bid. pub(super) fn see(&mut self, tick: &Tick) { if tick.side == TickSide::Sell { diff --git a/crates/moon-core/src/db/tuner/ticks/exit/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/tests.rs index d66c8738..f22c82e3 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/tests.rs @@ -49,6 +49,7 @@ pub(super) fn deal(short: bool) -> Deal { buy_set_ms: None, corridor: None, entry_placed: None, + gap: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/gap.rs b/crates/moon-core/src/db/tuner/ticks/gap.rs new file mode 100644 index 00000000..a7cbc826 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/gap.rs @@ -0,0 +1,211 @@ +//! The hole in a long position's tape, and what the fact proves about it. +//! +//! A position held past `[trade_replay] long_position_min` keeps its prints around the entry and +//! around the exit only (`ReplayWindow::focus_spans`); between them the tape holds nothing. A walk +//! that treats the first print after the hole as the next print of the market meets it with a sell +//! line that took hours of timer steps down in between, and "sells" there — an exit invented at the +//! seam (the spec, `ENTRY_EXIT_TUNER.md` §3.1). +//! +//! What the fact does prove: the core's own sell line was NOT reached inside the hole, and the +//! core's own stop did not fire there — or the trade would have closed inside it. So a variant +//! whose sell stands no nearer the price than the fact's line at every moment of the hole, and +//! whose stop stands no nearer than the fact's, was not closed inside it either, and the part of +//! the tape around the close judges it. A variant that stood nearer at some moment could have been +//! crossed then, at an unknown moment and price: it is not judged on the trade +//! ([`crate::db::tuner::ticks::ExitKind::InGap`]). So is one whose rule follows the price through +//! the hole — SellLevel, the pump move, the trailing stop, a stop ladder rung still to take — since +//! where such a rule stood is a function of prints nobody holds. + +use std::sync::Arc; + +use super::exit::ExitParams; +use super::exit::level_off_buy; +use super::exit::stops::stop_pct; +use super::verify::POINT_TIME_TOLERANCE_MS; +use super::{Deal, PRICE_EPS, PRICE_TOLERANCE}; +use crate::market::trade_replay::Coverage; + +/// How far apart in time a modelled level and the core's archived one may stand and still be one +/// line to the hole's comparison: the verdict's default for one move ([`POINT_TIME_TOLERANCE_MS`]). +/// The constant, not the verdict's setting: widening how strictly the model is JUDGED must not +/// widen what a variant may do unseen for hours. +pub const HOLE_TIME_SLACK_MS: i64 = POINT_TIME_TOLERANCE_MS; + +/// How far a modelled level may sit on the price's side of the core's archived one and still be +/// that level to the hole's comparison, relative: the verdict's default price tolerance +/// ([`PRICE_TOLERANCE`]), a constant for the same reason as [`HOLE_TIME_SLACK_MS`]. +pub const HOLE_PRICE_TOLERANCE: f64 = PRICE_TOLERANCE; + +/// The stretch between a long position's two held ends, and the fact's own record of it. +#[derive(Clone, Debug, PartialEq)] +pub struct TapeGap { + /// The last held millisecond before the hole: a print at or before it is the entry end's. + pub from_ms: i64, + /// The first held millisecond after the hole: a print at or after it is the exit end's. + pub to_ms: i64, + /// The core's sell line through the hole — the archived Exit line's `(t_ms, price)` points — + /// the level no print reached while it stood; `None` without the archive, and then no variant + /// with a sell standing in the hole can be told unreached. + pub fact_line: Option>, + /// The core's stop, which the fact proves quiet through the hole; `None` when the fact ran no + /// stop, or fired it before the hole ended — then nothing is proven about the prices there. + pub fact_stop: Option, +} + +/// The fact's stop as the hole's proof reads it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GapStop { + /// The deepest level the fact's stop could stand at inside the hole: the first stop's, or a + /// ladder rung's deeper still — every price of the hole stayed on the position's side of it. + pub level: f64, + /// `FastStopLoss` of the fact: its stop fired on any print through the level. + pub fast: bool, + /// `StopLossEMA` of the fact: the average a book-watching stop compares. + pub ema: f64, +} + +impl TapeGap { + /// The hole of a covered long position's window, when the held tape has one between the buy + /// and the close; `None` for a window held whole. + /// + /// Several holes (a middle partly held, a chart fetched a stretch of it) read as one: from the + /// end of the held run at the buy to the start of the held run at the close. What lies between + /// them is then taken as unknown, which can only leave a variant unjudged, never invent one. + /// + /// Args: + /// deal: The trade — its buy, close, side and deltas. + /// covered: What the tape store holds of the window. + /// exit: The sell parameters the trade ran, for the fact's stop. + /// exit_points: The archived Exit line, when the archive holds it. + pub fn of( + deal: &Deal, + covered: &Coverage, + exit: &ExitParams, + exit_points: Option<&[(i64, f64)]>, + ) -> Option { + let inside = covered.clip(&Coverage::one((deal.buy_ms, deal.close_ms))); + let spans = inside.spans(); + let (&(_, from_ms), &(to_ms, _)) = (spans.first()?, spans.last()?); + // One held run from the buy to the close: no hole. A covered row holds both ends; a row + // that does not is no trade of the model's and has no hole to speak of. + if spans.len() < 2 || !inside.contains_ms(deal.buy_ms) || !inside.contains_ms(deal.close_ms) + { + return None; + } + Some(Self { + from_ms, + to_ms, + fact_line: exit_points.filter(|p| !p.is_empty()).map(Arc::from), + fact_stop: fact_stop(deal, exit, to_ms), + }) + } + + /// The core's sell level at `t_ms`: the archived point in force then, `None` before the line's + /// first point or without the archive. + pub fn fact_level_at(&self, t_ms: i64) -> Option { + level_at(self.fact_line.as_deref()?, t_ms) + } +} + +/// The fact's stop as it bounds the hole's prices, `None` when it bounds nothing: no stop, or one +/// the fact proves quiet only up to a moment before the hole ends (`StopAnchor::quiet_until_ms`, +/// its activation on a stopped trade) — a stop that may have fired inside the hole proves nothing +/// about the prices there. +fn fact_stop(deal: &Deal, exit: &ExitParams, hole_end_ms: i64) -> Option { + let pct = stop_pct(exit, deal, deal.buy_ms); + if pct == 0.0 { + return None; + } + let quiet_until = deal + .stop_anchor + .map_or(deal.close_ms, |anchor| anchor.quiet_until_ms); + if quiet_until < hole_end_ms { + return None; + } + let long = deal.is_long(); + let deepest = [ + Some(pct), + exit.second_stop.map(|s| s.level_pct), + exit.third_stop.map(|s| s.level_pct), + ] + .into_iter() + .flatten() + .map(|pct| level_off_buy(deal.buy_price, pct, long)) + .reduce(|a, b| if long { a.min(b) } else { a.max(b) })?; + Some(GapStop { + level: deepest, + fast: exit.fast_stop_loss, + ema: exit.stop_loss_ema, + }) +} + +/// The level of a stepped line at `t_ms`: the last point stamped at or before it; `None` before +/// the first. +pub(super) fn level_at(points: &[(i64, f64)], t_ms: i64) -> Option { + points + .iter() + .filter(|(t, _)| *t <= t_ms) + .max_by_key(|(t, _)| *t) + .map(|&(_, price)| price) +} + +/// The core's sell level at `t_ms` as the hole's comparison reads it: of the levels its archived +/// line stood at within `slack_ms` either side, the one nearest the price — the lowest for a long, +/// whose sell the price comes up to, the highest for a short — so the loosest bound. A modelled +/// move and an archived one within [`POINT_TIME_TOLERANCE_MS`] are one move to the verdict; a +/// comparison with no slack read the trade's OWN settings as +/// nearer the price than the core's line whenever a modelled step fell a fraction of a second +/// before the archived one — 22 of the 1 703 fit trades of the live bench (2026-09-25), 18 of them +/// PumpsDetection's long PriceDown chains. `None` where the line is not on record at `t_ms`. +pub(super) fn fact_level_near( + points: &[(i64, f64)], + t_ms: i64, + slack_ms: i64, + long: bool, +) -> Option { + let from = t_ms.saturating_sub(slack_ms.max(0)); + let to = t_ms.saturating_add(slack_ms.max(0)); + let at_start = level_at(points, from); + let inside = points + .iter() + .filter(|(t, _)| *t > from && *t <= to) + .map(|&(_, p)| p); + at_start + .into_iter() + .chain(inside) + .reduce(|a, b| if long { a.min(b) } else { a.max(b) }) +} + +/// Whether a variant's sell at `variant` stands no nearer the price than the fact's at `fact`: at +/// or above it for a long (the price comes UP to a long's sell), at or below it for a short — +/// within `tolerance`, relative — [`HOLE_PRICE_TOLERANCE`] from the walk — and never under +/// [`PRICE_EPS`], the `f32` of the archive. +pub(super) fn sell_not_nearer(variant: f64, fact: f64, long: bool, tolerance: f64) -> bool { + let tolerance = tolerance.max(PRICE_EPS); + if long { + variant >= fact * (1.0 - tolerance) + } else { + variant <= fact * (1.0 + tolerance) + } +} + +/// Whether a variant's stop at `variant` stands no nearer the price than the fact's bound: at or +/// below it for a long (the price comes DOWN to a long's stop), at or above it for a short. +pub(super) fn stop_not_nearer(variant: f64, bound: f64, long: bool) -> bool { + if long { + variant <= bound * (1.0 + PRICE_EPS) + } else { + variant >= bound * (1.0 - PRICE_EPS) + } +} + +/// Whether a variant's stop trigger is no quicker than the fact's: a stop firing on any print +/// (`FastStopLoss`) is the quickest, so any trigger is no quicker than it; otherwise the same +/// trigger — a book-watching stop averaged differently fires at other moments, and neither is +/// bounded by the other. +pub(super) fn trigger_not_quicker(variant_fast: bool, variant_ema: f64, fact: &GapStop) -> bool { + fact.fast || (!variant_fast && variant_ema == fact.ema) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/gap/tests.rs b/crates/moon-core/src/db/tuner/ticks/gap/tests.rs new file mode 100644 index 00000000..e8db9049 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/gap/tests.rs @@ -0,0 +1,353 @@ +//! The hole of a long position's tape: where it lies, and what the fact's stop bounds. + +use super::*; +use crate::db::tuner::ticks::exit::line::{walk, walk_held}; +use crate::db::tuner::ticks::{Deal, EntryParams, ExitKind, Fill, ModelSettings, simulate}; +use crate::feed::types::{Side, Tick}; + +/// A long bought at 100 at the epoch, closed two hours later. +fn deal(short: bool) -> Deal { + Deal { + buy_ms: 0, + close_ms: 2 * HOUR, + buy_price: 100.0, + is_short: short, + ..crate::db::tuner::ticks::tests::deal() + } +} + +const HOUR: i64 = 3_600_000; + +/// A two-hour long position held at its two ends: the hole runs between the runs. +#[test] +fn a_long_positions_hole_lies_between_its_held_ends() { + let d = deal(false); + let covered = Coverage::from_spans([(-30_000, 30_000), (2 * HOUR - 30_000, 2 * HOUR + 30_000)]); + let gap = TapeGap::of(&d, &covered, &ExitParams::default(), None).expect("a hole"); + assert_eq!((gap.from_ms, gap.to_ms), (30_000, 2 * HOUR - 30_000)); + assert_eq!(gap.fact_line, None); + assert_eq!(gap.fact_stop, None, "the default sell runs no stop"); +} + +/// A window held whole has no hole, nor has one missing an end. +#[test] +fn a_window_held_whole_has_no_hole() { + let d = deal(false); + let whole = Coverage::one((-30_000, 2 * HOUR + 30_000)); + assert_eq!(TapeGap::of(&d, &whole, &ExitParams::default(), None), None); + let entry_only = Coverage::one((-30_000, 30_000)); + assert_eq!( + TapeGap::of(&d, &entry_only, &ExitParams::default(), None), + None + ); +} + +/// Several holes read as one, from the run at the buy to the run at the close. +#[test] +fn a_middle_held_in_part_reads_as_one_hole() { + let d = deal(false); + let covered = Coverage::from_spans([ + (-30_000, 30_000), + (HOUR, HOUR + 60_000), + (2 * HOUR - 30_000, 2 * HOUR + 30_000), + ]); + let gap = TapeGap::of(&d, &covered, &ExitParams::default(), None).expect("a hole"); + assert_eq!((gap.from_ms, gap.to_ms), (30_000, 2 * HOUR - 30_000)); +} + +/// The fact's stop bounds the hole at its deepest level — a deeper ladder rung included — and +/// only while the fact proves it quiet through the hole. +#[test] +fn the_facts_stop_bounds_the_hole_at_its_deepest_level() { + use crate::db::tuner::ticks::exit::StopStep; + let mut d = deal(false); + let covered = Coverage::from_spans([(-30_000, 30_000), (2 * HOUR - 30_000, 2 * HOUR + 30_000)]); + let exit = ExitParams { + stop_loss_pct: -2.0, + second_stop: Some(StopStep { + after_s: 60.0, + switch_pct: 1.0, + level_pct: -5.0, + }), + fast_stop_loss: false, + stop_loss_ema: 3.0, + ..ExitParams::default() + }; + let gap = TapeGap::of(&d, &covered, &exit, Some(&[(0, 101.0)])).expect("a hole"); + let stop = gap.fact_stop.expect("a stop"); + assert!((stop.level - 95.0).abs() < 1e-9, "{stop:?}"); + assert!(!stop.fast); + assert_eq!(gap.fact_level_at(HOUR), Some(101.0)); + assert_eq!(gap.fact_level_at(-1), None, "before the line's first point"); + + // A short's deepest stop is the highest one. + let mut short = d.clone(); + short.is_short = true; + let stop = TapeGap::of(&short, &covered, &exit, None) + .and_then(|g| g.fact_stop) + .expect("a stop"); + assert!((stop.level - 100.0 / 0.95).abs() < 1e-9, "{stop:?}"); + + // A stop the fact fired inside the hole proves nothing about it. + d.stop_anchor = Some(crate::db::tuner::ticks::StopAnchor { + entry_price: 100.0, + entry_ms: 0, + stop_pct: -2.0, + delay_s: 0.0, + fast: false, + ema: 3.0, + second: exit.second_stop, + third: None, + fired: Some((HOUR, 98.0)), + quiet_until_ms: HOUR, + }); + let gap = TapeGap::of(&d, &covered, &exit, None).expect("a hole"); + assert_eq!(gap.fact_stop, None); +} + +#[test] +fn a_quicker_trigger_is_not_bounded_by_a_slower_one() { + let book = GapStop { + level: 98.0, + fast: false, + ema: 3.0, + }; + assert!( + !trigger_not_quicker(true, 0.0, &book), + "any print beats the ticker" + ); + assert!(!trigger_not_quicker(false, 0.0, &book), "another average"); + assert!(trigger_not_quicker(false, 3.0, &book)); + let fast = GapStop { fast: true, ..book }; + assert!(trigger_not_quicker(true, 0.0, &fast)); + assert!(trigger_not_quicker(false, 5.0, &fast)); +} + +// ---- the walk across the hole ------------------------------------------------------------- + +/// Where the entry end's prints stop and the exit end's begin. +const HOLE_FROM: i64 = 30_000; +const HOLE_TO: i64 = 2 * HOUR - 30_000; + +fn print(t_ms: i64, price: f64) -> Tick { + Tick { + time_ms: t_ms as f64, + price: price as f32, + qty: 1.0, + side: Side::Buy, + } +} + +/// A long bought at 100 and held two hours, its tape held at both ends only; the core's line +/// and stop as given. +fn holed(fact_line: Option<&[(i64, f64)]>, fact_stop: Option) -> Deal { + Deal { + gap: Some(TapeGap { + from_ms: HOLE_FROM, + to_ms: HOLE_TO, + fact_line: fact_line.map(Arc::from), + fact_stop, + }), + ..deal(false) + } +} + +/// Prints below the take at the entry end, one print at the take's 101 ten seconds before the +/// close. +fn two_ends() -> Vec { + vec![ + print(1_000, 100.3), + print(20_000, 100.3), + print(HOLE_TO + 5_000, 100.6), + print(2 * HOUR - 10_000, 101.0), + ] +} + +fn fill() -> Fill { + Fill { + t_ms: 0, + price: 100.0, + } +} + +/// A 1 % take and no latency. +fn sell() -> ExitParams { + ExitParams { + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + ..ExitParams::default() + } +} + +/// The spec's §3.1 seam: a line PriceDown took to its floor stood under the core's line through +/// two hours nobody holds, and the walk sold it on the first print past the hole. Where the price +/// crossed it — if it did — is on no record: the trade is not judged. +#[test] +fn a_line_stepped_under_the_facts_is_not_sold_on_the_seam() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.5, + ..sell() + }; + let d = holed(Some(&[(0, 101.0)]), None); + let w = walk(&d, &two_ends(), fill(), 101.0, &p); + assert_eq!(w.exit.kind, ExitKind::InGap, "{:?}", w.exit); +} + +/// A sell no nearer the price than the core's line through the hole was not reached there: the +/// exit end judges it, the take filled by the print at it. +#[test] +fn a_line_no_nearer_than_the_facts_is_judged_on_the_exit_end() { + let d = holed(Some(&[(0, 101.0), (HOUR, 100.8)]), None); + let w = walk(&d, &two_ends(), fill(), 101.0, &sell()); + assert_eq!( + (w.exit.kind, w.exit.t_ms), + (ExitKind::Take, 2 * HOUR - 10_000), + "{:?}", + w.exit + ); +} + +/// Without the core's line on record nothing proves a standing sell unreached. +#[test] +fn without_the_archive_a_standing_sell_is_not_judged() { + let d = holed(None, None); + let w = walk(&d, &two_ends(), fill(), 101.0, &sell()); + assert_eq!(w.exit.kind, ExitKind::InGap); +} + +/// A stop nearer the price than the core's, or on a quicker trigger, may have fired in the hole; +/// a deeper one on the same trigger did not. +#[test] +fn a_stop_nearer_than_the_facts_is_not_judged_and_a_deeper_one_is() { + let fact_stop = Some(GapStop { + level: 97.0, + fast: false, + ema: 0.0, + }); + let d = holed(Some(&[(0, 101.0)]), fact_stop); + let stop = |pct: f64, fast: bool| ExitParams { + stop_loss_pct: pct, + fast_stop_loss: fast, + ..sell() + }; + let near = walk(&d, &two_ends(), fill(), 101.0, &stop(-1.0, false)); + assert_eq!( + near.exit.kind, + ExitKind::InGap, + "99 stands above the core's 97" + ); + let quick = walk(&d, &two_ends(), fill(), 101.0, &stop(-5.0, true)); + assert_eq!(quick.exit.kind, ExitKind::InGap, "a print beats the ticker"); + let deep = walk(&d, &two_ends(), fill(), 101.0, &stop(-5.0, false)); + assert_eq!(deep.exit.kind, ExitKind::Take, "{:?}", deep.exit); + // No stop of the core's to lean on. + let bare = holed(Some(&[(0, 101.0)]), None); + let w = walk(&bare, &two_ends(), fill(), 101.0, &stop(-5.0, false)); + assert_eq!(w.exit.kind, ExitKind::InGap); +} + +/// SellLevel follows the price's high: where it stood after the hole is a function of prints +/// nobody holds. +#[test] +fn a_rule_following_the_price_is_not_judged_across_the_hole() { + let p = ExitParams { + sell_level_delay_s: 1.0, + sell_level_time_s: 10.0, + sell_level_count: 5_000, + // Half a per cent over the high: nothing at the entry end reaches it. + sell_level_adjust_pct: 0.5, + ..sell() + }; + let d = holed(Some(&[(0, 101.0)]), None); + let w = walk(&d, &two_ends(), fill(), 101.0, &p); + assert_eq!(w.exit.kind, ExitKind::InGap); +} + +/// The verdict holds the sell through the close and judges where the line stood there: the +/// timer steps through the hole need no print and no archive, and nothing is sold on the seam. +#[test] +fn the_verdict_steps_the_line_through_the_hole_on_its_timer() { + let p = ExitParams { + price_down_timer_s: 1.0, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.5, + ..sell() + }; + let d = holed(None, None); + let w = walk_held(&d, &two_ends(), fill(), 101.0, &p, Some(d.close_ms)); + assert_ne!(w.exit.kind, ExitKind::InGap, "{:?}", w.exit); + let last = w.points.last().expect("the line's levels"); + assert!((last.price - 100.5).abs() < 1e-9, "at the floor: {last:?}"); +} + +/// An entry the tape shows filling only past the hole's start filled inside it, or never: not +/// judged, and a search point leaving it so is refused like one left open. +#[test] +fn an_entry_filling_past_the_holes_start_is_not_judged() { + use crate::db::tuner::ticks::MshotParams; + let d = Deal { + buy_ms: 0, + ..holed(Some(&[(0, 101.0)]), None) + }; + let ticks = vec![ + print(-10_000, 100.0), + print(1_000, 100.0), + print(HOLE_TO + 1_000, 98.5), + print(2 * HOUR - 10_000, 101.0), + ]; + let entry = EntryParams::MoonShot(MshotParams::default()); + let outcome = simulate(&d, &ticks, &entry, &sell(), None); + assert_eq!( + outcome.exit.map(|e| e.kind), + Some(ExitKind::InGap), + "{outcome:?}" + ); + assert!(outcome.left_open()); + assert_eq!(outcome.profit_pct, None); +} + +/// The core's level near a moment is the loosest one it stood at within the slack: the lowest +/// for a long, the highest for a short; nothing where the line is not on record. +#[test] +fn the_facts_level_near_a_moment_is_the_loosest_within_the_slack() { + let line = [(0, 101.0), (60_000, 100.5), (120_000, 100.8)]; + assert_eq!(fact_level_near(&line, 59_500, 1_000, true), Some(100.5)); + assert_eq!(fact_level_near(&line, 58_500, 1_000, true), Some(101.0)); + assert_eq!(fact_level_near(&line, 119_500, 1_000, false), Some(100.8)); + assert_eq!(fact_level_near(&line, -2_000, 1_000, true), None); +} + +/// A PriceDown step the model takes within a second of the core's archived one is that step: the +/// trade's own settings are judged on the exit end. Five seconds early the model's sell stood +/// under the core's for those seconds, and the trade is not judged. +#[test] +fn a_step_within_the_verdicts_slack_is_the_facts_own() { + let archive: &[(i64, f64)] = &[(0, 101.0), (60_000, 100.5)]; + let d = holed(Some(archive), None); + // One relative step of 50 % off 101 to the 100.5 floor. + let step_at = |timer_s: f64| ExitParams { + price_down_timer_s: timer_s, + price_down_pct: 50.0, + price_down_delay_s: 1.0, + price_down_relative: true, + price_down_allowed_drop_pct: 0.5, + ..sell() + }; + let near = walk(&d, &two_ends(), fill(), 101.0, &step_at(59.5)); + assert_eq!( + (near.exit.kind, near.exit.t_ms), + (ExitKind::Line, HOLE_TO + 5_000), + "{:?}", + near.exit + ); + let early = walk(&d, &two_ends(), fill(), 101.0, &step_at(55.0)); + assert_eq!(early.exit.kind, ExitKind::InGap, "{:?}", early.exit); +} diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index ea1eb240..fd93ccae 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -30,6 +30,7 @@ pub mod deals; pub mod deltas; pub mod entry; pub mod exit; +pub mod gap; pub mod hook; pub mod mshot; pub mod params; @@ -270,6 +271,11 @@ pub struct Deal { /// nothing to model on the entry side: the fill is the report's ([`simulate`]). `None` /// until the model inputs are filled, and in the verdict, which exists to test the model. pub own_entry: Option, + /// The hole between a long position's two held ends and the fact's record of it + /// ([`gap::TapeGap`]); filled by the caller that holds the tape's coverage, `None` for a + /// window held whole. A variant that may have closed inside it is not judged on the trade + /// ([`ExitKind::InGap`]). + pub gap: Option, } impl Deal { @@ -402,6 +408,16 @@ pub enum ExitKind { /// and counted in the caption. Under the strategy's own parameters this is the model /// failing to reproduce an exit the core made, and the verdict says so. OpenAtWindowEnd, + /// The variant may have closed inside the hole of a long position's tape ([`gap`]) — its sell + /// stood nearer the price than the fact's line there, its stop nearer than the fact's, or a + /// rule of it followed the price through the hole — at a moment and a price nobody holds. Or + /// its entry filled only after the hole began: whether the order waited through the hole + /// unfilled, or filled inside it, nobody can tell — even when the tape shows a fill on the + /// exit end, it shows one the order may never have lived to see. Not a trade, and no more a + /// loss than a win: like + /// [`Self::OpenAtWindowEnd`] it is out of the KPI, and a search point that leaves a trade + /// here is refused ([`Outcome::left_open`]). + InGap, } /// Where and when the modelled position closed, and by which rule. @@ -420,7 +436,8 @@ pub struct Outcome { pub fill: Option, pub exit: Option, /// Signed result in per cent of the fill price, sign already flipped for a short. `None` - /// without a fill, with an [`ExitKind::OpenAtWindowEnd`] exit, or when either price is not + /// without a fill, with an [`ExitKind::OpenAtWindowEnd`] or [`ExitKind::InGap`] exit, or when + /// either price is not /// a price (see [`profit_pct`]) — none of those is a trade. pub profit_pct: Option, } @@ -436,12 +453,13 @@ impl Outcome { self.profit_pct.is_some() } - /// Whether the position was bought and nothing closed it inside the tape. + /// Whether the position was bought and nothing closed it inside the tape — past its end, or + /// inside a hole of it ([`ExitKind::InGap`]): either way what the trade made is on no record. pub fn left_open(&self) -> bool { self.fill.is_some() - && self - .exit - .is_some_and(|exit| exit.kind == ExitKind::OpenAtWindowEnd) + && self.exit.is_some_and(|exit| { + matches!(exit.kind, ExitKind::OpenAtWindowEnd | ExitKind::InGap) + }) } } @@ -553,9 +571,19 @@ pub fn simulate( profit_pct: None, }; }; - let exit_result = ExitModel::new(exit).exit(deal, ticks, fill); + // An entry the tape shows filling only after a long position's hole began — on the first + // print past it, most often — filled somewhere in the hole, or never: nobody holds the prints + // that would say. The fact's own entry is at the buy, before any hole. + let exit_result = match &deal.gap { + Some(gap) if fill.t_ms > gap.from_ms => Exit { + t_ms: fill.t_ms, + price: f64::NAN, + kind: ExitKind::InGap, + }, + _ => ExitModel::new(exit).exit(deal, ticks, fill), + }; let profit_pct = match exit_result.kind { - ExitKind::OpenAtWindowEnd => None, + ExitKind::OpenAtWindowEnd | ExitKind::InGap => None, _ => profit_pct(deal, fill.price, exit_result.price), }; Outcome { diff --git a/crates/moon-core/src/db/tuner/ticks/record.rs b/crates/moon-core/src/db/tuner/ticks/record.rs index e3fc1b37..9b07bd85 100644 --- a/crates/moon-core/src/db/tuner/ticks/record.rs +++ b/crates/moon-core/src/db/tuner/ticks/record.rs @@ -20,10 +20,12 @@ use super::exit::sell_order::{archived_pre_spike_ask, archived_take}; use super::exit::stops::stop_pct; use super::exit::{ExitParams, StopStep}; +use super::gap::TapeGap; use super::verify::{ POINT_TIME_TOLERANCE_MS, Verdict, archived_stop_jump, is_stop_reason, stop_jump_level, }; use super::{Deal, EntryParams, Fill}; +use crate::market::trade_replay::Coverage; /// The fact's stop, as a variant running the same one may lean on it. #[derive(Clone, Copy, Debug, PartialEq)] @@ -119,20 +121,29 @@ pub struct OwnLines<'a> { /// Fill the model inputs the core's own record gives: the ask a MoonShot's take was lifted to, /// read back off the take as placed, the take itself, the level the entry order was placed at -/// ([`entry_placement`]), the stop anchor, and the entry settings the trade ran with -/// ([`Deal::own_entry`]). +/// ([`entry_placement`]), the stop anchor, the entry settings the trade ran with +/// ([`Deal::own_entry`]) and the hole of a long position's tape ([`Deal::gap`]). /// /// Args: /// deal: The trade, filled in place. /// entry: The entry parameters as of the buy. /// exit: The sell parameters as of the buy. /// lines: The trade's own archived lines. -pub fn prepare_deal(deal: &mut Deal, entry: &EntryParams, exit: &ExitParams, lines: OwnLines<'_>) { +/// covered: What the tape store holds of the trade's window. +pub fn prepare_deal( + deal: &mut Deal, + entry: &EntryParams, + exit: &ExitParams, + lines: OwnLines<'_>, + covered: &Coverage, +) { deal.pre_spike_ask = archived_pre_spike_ask(lines.exit, exit, deal.is_short); deal.archived_take = archived_take(lines.exit); deal.entry_placed = entry_placement(deal, lines); deal.stop_anchor = Some(StopAnchor::of(deal, exit, lines.exit)); deal.own_entry = Some(entry.clone()); + // After the anchor: the hole's proof of the fact's stop reads how long it stayed quiet. + deal.gap = TapeGap::of(deal, covered, exit, lines.exit); } /// The level the entry order stood at when the core created it ([`Deal::entry_placed`]). diff --git a/crates/moon-core/src/db/tuner/ticks/record/tests.rs b/crates/moon-core/src/db/tuner/ticks/record/tests.rs index 9d1bb27a..135a4086 100644 --- a/crates/moon-core/src/db/tuner/ticks/record/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -6,6 +6,7 @@ use crate::db::tuner::ticks::exit::line::walk; use crate::db::tuner::ticks::mshot::MshotParams; use crate::db::tuner::ticks::{Deltas, ExitKind, ModelSettings, simulate, verify}; use crate::feed::types::{Side, Tick}; +use crate::market::trade_replay::Coverage; fn tick(t_ms: i64, price: f64) -> Tick { Tick { @@ -59,6 +60,7 @@ fn stopped() -> Deal { buy_set_ms: None, corridor: None, entry_placed: None, + gap: None, } } @@ -250,7 +252,13 @@ fn a_variant_running_the_trades_own_entry_fills_at_the_fact() { let mut d = stopped(); d.kind = "MoonShot".into(); let own = EntryParams::MoonShot(MshotParams::default()); - prepare_deal(&mut d, &own, &book(), OwnLines::default()); + prepare_deal( + &mut d, + &own, + &book(), + OwnLines::default(), + &Coverage::none(), + ); // A tape the corridor never reaches: the model alone would not fill at all. let ticks = vec![ tick(-20_000, 100.0), diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index b46abb82..5a17557e 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -49,6 +49,7 @@ pub(super) fn prepared(uid: i64, peak: f64) -> PreparedDeal { buy_set_ms: None, corridor: None, entry_placed: None, + gap: None, }; let t0 = deal.buy_ms; let ticks: Vec = vec![ diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index 2a9da4cd..02254e19 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -32,6 +32,7 @@ fn deal(pnl: f64, spent: f64) -> Deal { buy_set_ms: None, corridor: None, entry_placed: None, + gap: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 005c3439..1e2056b2 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -59,6 +59,7 @@ pub(super) fn deal() -> Deal { buy_set_ms: None, corridor: None, entry_placed: None, + gap: None, } } diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 70732705..01daddd3 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -125,6 +125,7 @@ fn dump_deal( "tick": deal.tick, "values": values, "held_exit": [held.exit.t_ms, held.exit.price, format!("{:?}", held.exit.kind)], + "gap": deal.gap.as_ref().map(|g| (g.from_ms, g.to_ms)), "held_points": held.points.iter().map(|p| (p.t_ms, p.price)).collect::>(), "archive": exit_points, "entry": entry_points, @@ -623,6 +624,8 @@ fn real_data_reproduction() { // reads as that in the summary rather than as a scope without tape. let mut no_venue = 0usize; let (mut fit_n, mut own_n, mut own_close) = (0usize, 0usize, 0usize); + // Long positions whose tape has a hole, and those the trade's own settings leave in it. + let (mut holes, mut own_in_gap) = (0usize, 0usize); // MoonShot entries replayed from the order's creation (`mshot`, `Deal::entry_placed`), and // how many of those the model reproduced. let (mut created_n, mut created_hits, mut stamped) = (0usize, 0usize, 0usize); @@ -770,6 +773,7 @@ fn real_data_reproduction() { exit: exit_points.as_deref(), answered, }, + &covered, ); // The corridor the core saved against the model's band, `near` … `2 · far − near` off one // reference (`mshot`): the ratio of its edges is the band's width whatever the reference. @@ -1049,6 +1053,8 @@ fn real_data_reproduction() { // variant column counts the same money the "Fact" column does. let fit = fit_for_search(&archived); let own = simulate(&deal, &ticks, &entry, &exit, entry_line.as_deref()); + holes += usize::from(deal.gap.is_some()); + own_in_gap += usize::from(own.exit.is_some_and(|e| e.kind == ExitKind::InGap)); // `MOON_TICKS_VARIANT="SellPrice=1.6,PriceDownTimer=3"` replays the deal under those values // laid over its own, the way a variant column does, and prints the line it walked. if let Ok(spec) = std::env::var("MOON_TICKS_VARIANT") { @@ -1289,6 +1295,7 @@ fn real_data_reproduction() { let mut unfit: Vec<(String, usize)> = unfit.into_iter().collect(); unfit.sort_by_key(|u| std::cmp::Reverse(u.1)); eprintln!("fit for the search: {fit_n} of {with_tape} · left out: {unfit:?}"); + eprintln!("long positions with a hole: {holes} · own settings left in it: {own_in_gap}"); eprintln!( "own settings replayed on the fit trades: {own_n} closed, mean {:.3} % against the fact's {:.3} %, within 0.05 pp on {own_close}", own_sum / own_n.max(1) as f64, diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index 6a10f03f..a3f79052 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -194,6 +194,8 @@ pub fn verify( { walked.exit } + // No level the line reached through the tape's hole is the rules' (`gap`). + ExitKind::InGap => walked.exit, _ => { // In time order: the take is stamped when it is armed, after any timer step // that fell due inside the sell delay. @@ -247,6 +249,11 @@ pub fn verify( // A rule the model does not have was on: whatever the walk made of the trade is not // an answer about it (see `ExitParams::unmodelled`). (None, None, None) + } else if closed.kind == ExitKind::InGap { + // A rule of the trade follows the price through its tape's hole, or its stop is not + // bounded by what the fact proves there: where the line or the stop stood at the close + // is a function of prints nobody holds (`gap`). + (None, None, None) } else if missed_stop { (Some(false), None, None) } else if !take_known { @@ -724,7 +731,7 @@ fn exit_rule_matches(kind: ExitKind, sell_reason: &str) -> bool { ExitKind::Take => reason.eq_ignore_ascii_case(REASON_TAKE), ExitKind::Line => REASONS_LINE.iter().any(|r| starts(r)), ExitKind::Stop => is_stop_reason(reason), - ExitKind::OpenAtWindowEnd => false, + ExitKind::OpenAtWindowEnd | ExitKind::InGap => false, } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs index 59493738..d36e006d 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs @@ -35,6 +35,7 @@ fn row(tape: TapeStatus) -> DealRow { buy_set_ms: None, corridor: None, entry_placed: None, + gap: None, }, tape, verdict: None, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index fae69246..18d23ec9 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -940,6 +940,7 @@ pub(super) fn replay_row_with( exit: lines.exit_points.as_deref(), answered: lines.answered, }, + &covered, ); // The core's own clock for its PriceDown steps, as the last load calibrated it. row.deal.step_lag_ms = super::lags::step_lag_of(row.deal.core_uid); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index e8c382b8..72e8afd2 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -35,6 +35,7 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { buy_set_ms: None, corridor: None, entry_placed: None, + gap: None, } } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs index 5c300691..c13ee9f3 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs @@ -200,7 +200,9 @@ impl AnalyticsView { fill_price: fill.price as f32, exit: outcome .exit - .filter(|exit| exit.kind != ExitKind::OpenAtWindowEnd) + .filter(|exit| { + !matches!(exit.kind, ExitKind::OpenAtWindowEnd | ExitKind::InGap) + }) .map(|exit| (exit.t_ms as f64, exit.price as f32)), exit_path: picture .sell_line diff --git a/locales/analytics.yml b/locales/analytics.yml index 3439b042..551fc322 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1921,9 +1921,9 @@ analytics.ticks.params_title: en: "Parameters" es: "Parámetros" analytics.ticks.assumptions: - ru: "Модель не знает: стакан и очередь на уровне · правила выхода вне модели (лестница стопов, SellShot, SellSpread) · MShotRepeat* · опора ASK/BID — последний принт своей стороны" - en: "The model does not know: the book and the queue at a level · exit rules outside it (the stop ladder, SellShot, SellSpread) · MShotRepeat* · the ASK/BID reference is the last print of its side" - es: "El modelo no conoce: el libro y la cola en un nivel · reglas de salida fuera de él (escalera de stops, SellShot, SellSpread) · MShotRepeat* · la referencia ASK/BID es el último print de su lado" + ru: "Модель не знает: стакан и очередь на уровне · правила выхода вне модели (SellShot, SellSpread) · MShotRepeat* · опора ASK/BID — последний принт своей стороны · середину долгой позиции: вариант, чья линия или стоп стояли там ближе к цене, чем у факта, не судится" + en: "The model does not know: the book and the queue at a level · exit rules outside it (SellShot, SellSpread) · MShotRepeat* · the ASK/BID reference is the last print of its side · the middle of a long position: a variant whose line or stop stood nearer the price there than the fact's is not judged" + es: "El modelo no conoce: el libro y la cola en un nivel · reglas de salida fuera de él (SellShot, SellSpread) · MShotRepeat* · la referencia ASK/BID es el último print de su lado · el medio de una posición larga: una variante cuya línea o stop estuvo allí más cerca del precio que el del hecho no se juzga" analytics.ticks.group_entry: ru: "Вход" en: "Entry" @@ -2089,9 +2089,9 @@ analytics.ticks.acc_tip_no_tape: en: "Without tape: %{n}, left out of the estimate." es: "Sin cinta: %{n}, fuera de la estimación." analytics.ticks.acc_tip_assumptions: - ru: "Допущения модели: стакана нет — очередь перед нашим ордером не моделируется, филл = касание принтом; стоп по стакану видит цену тикера раз в %{ticker} с, вместо неё — последний принт; задержка ядра и биржи — одна на все ядра, %{latency} мс; совпадением считается цена в пределах %{price} % и момент в пределах %{time} с; поля выхода вне модели (SellShot, SellSpread и другие) — такие сделки не судятся." - en: "The model assumes: no order book — the queue ahead of our order is not modelled, a fill is a touching print; a book-watching stop sees the ticker price every %{ticker} s, the last print standing in for it; one core and venue latency for every core, %{latency} ms; a match is a price within %{price} % and a moment within %{time} s; exit fields outside the model (SellShot, SellSpread and others) leave such trades unjudged." - es: "El modelo supone: sin libro de órdenes — la cola delante de nuestra orden no se modela, un llenado es un print que toca; un stop por libro ve el precio del ticker cada %{ticker} s, con el último print en su lugar; una latencia de núcleo y bolsa para todos los núcleos, %{latency} ms; coincide un precio dentro de %{price} % y un momento dentro de %{time} s; los campos de salida fuera del modelo (SellShot, SellSpread y otros) dejan esas operaciones sin juzgar." + ru: "Допущения модели: стакана нет — очередь перед нашим ордером не моделируется, филл = касание принтом; стоп по стакану видит цену тикера раз в %{ticker} с, вместо неё — последний принт; задержка ядра и биржи — одна на все ядра, %{latency} мс; совпадением считается цена в пределах %{price} % и момент в пределах %{time} с; поля выхода вне модели (SellShot, SellSpread и другие) — такие сделки не судятся; середина долгой позиции не хранится — вариант, чья линия или стоп стояли там ближе к цене, чем у факта, на такой сделке не судится." + en: "The model assumes: no order book — the queue ahead of our order is not modelled, a fill is a touching print; a book-watching stop sees the ticker price every %{ticker} s, the last print standing in for it; one core and venue latency for every core, %{latency} ms; a match is a price within %{price} % and a moment within %{time} s; exit fields outside the model (SellShot, SellSpread and others) leave such trades unjudged; the middle of a long position is not kept — a variant whose line or stop stood nearer the price there than the fact's is not judged on that trade." + es: "El modelo supone: sin libro de órdenes — la cola delante de nuestra orden no se modela, un llenado es un print que toca; un stop por libro ve el precio del ticker cada %{ticker} s, con el último print en su lugar; una latencia de núcleo y bolsa para todos los núcleos, %{latency} ms; coincide un precio dentro de %{price} % y un momento dentro de %{time} s; los campos de salida fuera del modelo (SellShot, SellSpread y otros) dejan esas operaciones sin juzgar; el medio de una posición larga no se guarda — una variante cuya línea o stop estuvo allí más cerca del precio que el del hecho no se juzga en esa operación." analytics.ticks.long_title: ru: "Долгий подбор" en: "A long search" diff --git a/locales/storage.yml b/locales/storage.yml index 50878394..6458ee00 100644 --- a/locales/storage.yml +++ b/locales/storage.yml @@ -129,17 +129,17 @@ storage.trades_min: en: "%{min} min" es: "%{min} min" storage.trades_margin_hint: - ru: "С каждого края сделки; у долгой — вокруг входа и вокруг выхода, середина свечами. Не меньше 30 с — столько нужно тюнеру." - en: "At each end of a trade; on a long one, around the entry and around the exit, candles between. At least 30 s — what the tuner needs." - es: "En cada extremo de una posición; en una larga, alrededor de la entrada y de la salida, velas entre ambas. Al menos 30 s: lo que necesita el afinador." + ru: "С каждого края сделки; у долгой — вокруг входа и вокруг выхода, середина свечами. Не меньше 30 с — столько нужно тюнеру; дальше этого после закрытия он выход варианта не судит." + en: "At each end of a trade; on a long one, around the entry and around the exit, candles between. At least 30 s — what the tuner needs; it judges a variant's exit no further than this past the close." + es: "En cada extremo de una posición; en una larga, alrededor de la entrada y de la salida, velas entre ambas. Al menos 30 s: lo que necesita el afinador, que no juzga la salida de una variante más allá de esto tras el cierre." storage.trades_long_position: ru: "Долгая сделка от" en: "Long trade from" es: "Posición larga desde" storage.trades_long_position_hint: - ru: "Дольше — трейды только вокруг входа и выхода." - en: "Held longer — prints around the entry and the exit only." - es: "Más larga — operaciones solo alrededor de la entrada y la salida." + ru: "Дольше — трейды только вокруг входа и выхода. Середину тюнер не видит: вариант, который мог выйти в ней, на такой сделке не судится." + en: "Held longer — prints around the entry and the exit only. The tuner does not see the middle: a variant that may have closed there is not judged on such a trade." + es: "Más larga — operaciones solo alrededor de la entrada y la salida. El afinador no ve el medio: una variante que pudo cerrar allí no se juzga en esa operación." storage.trades_autoload: ru: "Подгружать недостающую ленту при старте" en: "Fetch the missing tape at startup" From acc77fc2f2e251bf6ae50780a95cc2d5336d61d9 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sat, 26 Sep 2026 12:33:24 +0200 Subject: [PATCH 45/51] fix(tuner): the price grid off the tape, the hook's modifier sum and depth off the fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/db/tuner/ticks/calibrate/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/deals.rs | 1 + .../src/db/tuner/ticks/exit/delta_mods.rs | 271 +++++++++++++++++- .../db/tuner/ticks/exit/delta_mods/tests.rs | 227 ++++++++++++++- .../src/db/tuner/ticks/exit/sell_order.rs | 2 +- .../src/db/tuner/ticks/exit/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/hook.rs | 13 +- crates/moon-core/src/db/tuner/ticks/mod.rs | 26 +- crates/moon-core/src/db/tuner/ticks/record.rs | 40 ++- .../src/db/tuner/ticks/record/tests.rs | 1 + .../src/db/tuner/ticks/search/tests.rs | 1 + .../src/db/tuner/ticks/stats/tests.rs | 1 + crates/moon-core/src/db/tuner/ticks/tests.rs | 4 + .../src/db/tuner/ticks/tests/real_data.rs | 77 ++++- crates/moon-core/src/db/tuner/ticks/verify.rs | 8 +- crates/moon-core/src/market/source/read.rs | 14 +- .../tuner/ticks/delta_summary/tests.rs | 1 + .../src/analytics/tuner/ticks/fetch.rs | 2 - .../src/analytics/tuner/ticks/load.rs | 35 ++- .../src/analytics/tuner/ticks/rows/tests.rs | 1 + .../src/analytics/tuner/ticks/state.rs | 12 +- 21 files changed, 684 insertions(+), 55 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs index 92b484ea..7ccfeacc 100644 --- a/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs @@ -24,6 +24,7 @@ fn deal() -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + fact_modifier: None, hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, diff --git a/crates/moon-core/src/db/tuner/ticks/deals.rs b/crates/moon-core/src/db/tuner/ticks/deals.rs index 66d921ed..076ffda6 100644 --- a/crates/moon-core/src/db/tuner/ticks/deals.rs +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -225,6 +225,7 @@ fn read_on(conn: &Connection, q: &Query, src: &str) -> ReadResult { tick: None, pre_spike_ask: None, archived_take: None, + fact_modifier: None, // Filled with the model inputs, once the archive is in (`record::prepare_deal`). entry_placed: None, gap: None, diff --git a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs index c25a3d9b..c7555c90 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs @@ -1,9 +1,24 @@ //! The strategy window's "Delta Modifiers" section: the `Add*Delta` family, `SellModifier` and //! `MaxModifier` — one capped sum of the trade's deltas, spent on the take through //! `SellModifier` and on the stop through `StopLossModifier` ([`super::stops::stop_pct`]). +//! +//! **The core's own sum, off its record** ([`FactModifier`]). The core sums its LIVE deltas at +//! the moment it places the sell, and the report keeps one snapshot of them, stamped when the +//! entry order was placed — for a MoonHook a median 77 s before the fill, across the very dump +//! the hook buys. The model's deltas miss the core's sum by as much as the price moved in +//! between (2026-09-25, 723 archived hook takes: the snapshot's sum placed 329 of them within +//! 0.05 %, the live track 334). The record keeps the sum itself, spent: the take the core placed +//! and the stop level it printed are both `f(Σ)`, and read back they agree with each other (96 +//! trades holding both: a median gap of 0.021 in Σ, 92 within the price step) and not with the +//! snapshot (0.105). -use super::{ExitModel, ExitParams}; -use crate::db::tuner::ticks::Deal; +use super::sell_order::{archived_take, take_is_recorded}; +use super::{ExitModel, ExitParams, level_off_buy}; +use crate::db::tuner::ticks::mshot::Modifiers; +use crate::db::tuner::ticks::verify::{ + REASON_STOP, REASON_TAKE, reason_starts_with, stated_stop_level, +}; +use crate::db::tuner::ticks::{Deal, PRICE_TOLERANCE}; impl ExitModel<'_> { /// What the delta modifiers add to the sell level, per cent — the capped sum times @@ -25,15 +40,22 @@ impl ExitModel<'_> { /// (2026-09-22) the report's snapshot, stamped at the entry order's placement for every kind but /// MoonShot, drifted from the core's number the more, the longer the entry order waited. So the /// sum is read at `at_ms` through the deal's live coin deltas ([`Deal::deltas_at`]); the BTC, -/// market, mark and price-bug terms stay the snapshot, and on the stop the verdict absorbs their -/// residual in its level tolerance (`verify::STOP_PRICE_TOLERANCE`). +/// market, mark and price-bug terms stay the snapshot. Where the record kept the core's own sum +/// ([`Deal::fact_modifier`]), what the deltas miss of it is added back — scaled to these +/// coefficients ([`FactModifier::residual_for`]), so the fact's own parameters read a sum that +/// places the core's level to the price step, and a variant's the model's sum moved by the same +/// miss. /// /// Args: /// params: The sell parameters, for the coefficients and the ceiling. -/// deal: The trade, for its deltas. +/// deal: The trade, for its deltas and the core's own sum. /// at_ms: When the sell was placed — the fill. pub fn modifier_sum(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { - let sum = params.sell_mods.near_addition(&deal.deltas_at(at_ms)).abs(); + let model = model_sum(¶ms.sell_mods, deal, at_ms); + let sum = match deal.fact_modifier { + Some(fact) => (model + fact.residual_for(¶ms.sell_mods)).max(0.0), + None => model, + }; if params.max_modifier > 0.0 { sum.min(params.max_modifier) } else { @@ -41,5 +63,242 @@ pub fn modifier_sum(params: &ExitParams, deal: &Deal, at_ms: i64) -> f64 { } } +/// The sum as the deltas give it, uncapped and without the record's correction. +fn model_sum(mods: &Modifiers, deal: &Deal, at_ms: i64) -> f64 { + mods.near_addition(&deal.deltas_at(at_ms)).abs() +} + +/// The weights the record's miss is spread over, one per `Add*` term: the coefficient's +/// magnitude. The price-bug term is left out — its contribution has a ceiling of its own +/// (`Modifiers::pricebug_term`) and it never moves off the snapshot, so no part of the miss is +/// its. +fn weights(mods: &Modifiers) -> [f64; 15] { + [ + mods.add_5s, + mods.add_1m, + mods.add_5m, + mods.add_15m, + mods.add_1h, + mods.add_3h, + mods.add_24h, + mods.add_mark, + mods.add_btc_1h, + mods.add_btc_5m, + mods.add_btc_1m, + mods.add_market_1h, + mods.add_market_24h, + mods.add_pump_1h, + mods.add_dump_1h, + ] + .map(f64::abs) +} + +/// The total weight of a family's terms ([`weights`]). +fn weight(mods: &Modifiers) -> f64 { + weights(mods).iter().sum() +} + +/// The core's own delta-modifier sum on one trade, read back off its record, kept as what the +/// model's deltas miss of it — see the module doc. +/// +/// The miss is scaled for a variant by the total weight of its terms against the fact's +/// ([`weight`]). That reads the miss as the same number of per cent points on every term — the +/// move after the snapshot widens every range delta alike — so a variant that doubles every +/// coefficient doubles it, and one that zeroes them all leaves no sum at all, as the core would. +/// Magnitudes, so coefficients of both signs never cancel into a scale of nothing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FactModifier { + /// The core's sum less the model's own at the fill, both under the fact's coefficients. + residual: f64, + /// [`weight`] of the fact's terms; above zero by construction ([`FactModifier::of`]). + weight: f64, +} + +impl FactModifier { + /// The core's sum on a trade that ran `params`, off its record: the take the core placed — + /// the archived Exit line's first point, or the sale itself on a trade its untouched take + /// closed — and the stop level its reason printed (`StopLoss fixed: X`). A trade holding both + /// reads the sums both allow; where they do not meet, the take's, as `SellModifier` is the + /// larger coefficient on every live strategy that sets both, so one price step costs the + /// take's reading less. + /// + /// A level on the record is a price on the market's grid, so it gives not one sum but the + /// band of sums that round to it ([`Reading`]); the sum kept is the model's own where it lies + /// inside that band, else the band's nearer edge. A coarse step or a small coefficient widens + /// the band and leaves the model's sum alone — never amplifies the grid's rounding into a sum. + /// + /// A take-closed sale reads a limit's fill, and a fill a hair past its level reads a sum a + /// hair high; the verdict's own fill tolerance is the same size. + /// + /// Returns: + /// `None` when the trade runs no `Add*` term the miss can sit on ([`weights`] — a sum of + /// the price-bug term alone stays the model's), or the record holds neither reading. + /// + /// Args: + /// deal: The trade, with its live deltas and its hook depth already on it + /// (`Deal::delta_track`, `record::placed_hook_depth`). + /// params: The sell parameters as of the buy. + /// exit_points: The archived Exit line, when the archive holds it. + pub fn of( + deal: &Deal, + params: &ExitParams, + exit_points: Option<&[(i64, f64)]>, + ) -> Option { + let weight = weight(¶ms.sell_mods); + if weight <= 0.0 { + return None; + } + // Both readings are the one sum: where both exist, the sums both allow; where they do + // not overlap, the take's. + let reading = match ( + take_reading(deal, params, exit_points), + stop_reading(deal, params), + ) { + (Some(take), Some(stop)) => take.overlap(stop).unwrap_or(take), + (take, stop) => take.or(stop)?, + }; + let raw = model_sum(¶ms.sell_mods, deal, deal.buy_ms); + let cap = params.max_modifier; + let capped = if cap > 0.0 { raw.min(cap) } else { raw }; + let kept = reading.nearest(capped); + // A sum read at the cap says only that the core's reached it: the miss is at least what + // lifts the model's sum to the cap, and no more is known. + let residual = if cap > 0.0 && kept >= cap { + (cap - raw).max(0.0) + } else { + kept - raw + }; + Some(Self { residual, weight }) + } + + /// What the model's sum misses under `mods`: the fact's miss, scaled by the terms' weight. + pub fn residual_for(&self, mods: &Modifiers) -> f64 { + self.residual * weight(mods) / self.weight + } +} + +/// The sums a level on the record is consistent with: every sum whose level rounds to it on the +/// market's grid, `[low, high]`, never below zero (the core takes the magnitude). +#[derive(Clone, Copy, Debug, PartialEq)] +struct Reading { + low: f64, + high: f64, +} + +impl Reading { + /// The band `sum_at` maps a level's rounding interval onto: the level give or take just + /// under half a price step — the core placed it on the grid, the formula lands off it — or + /// give or take the price tolerance where the grid is unknown. + /// + /// Returns: + /// `None` for a level the formula cannot explain: its whole band below zero by more than + /// the price tolerance's worth of sum. + /// + /// Args: + /// deal: The trade, for its price step. + /// level: The level on the record. + /// coefficient: What the sum is multiplied by in the level's formula. + /// sum_at: The sum a level stands for. + fn of(deal: &Deal, level: f64, coefficient: f64, sum_at: impl Fn(f64) -> f64) -> Option { + if !(level.is_finite() && level > 0.0 && coefficient.is_finite()) || coefficient == 0.0 { + return None; + } + let half = match deal.tick.filter(|t| t.is_finite() && *t > 0.0) { + Some(step) => HALF_STEP_SHARE * step, + None => level * PRICE_TOLERANCE, + }; + let (a, b) = (sum_at(level - half), sum_at(level + half)); + let (low, high) = (a.min(b), a.max(b)); + let slack = PRICE_TOLERANCE * 100.0 / coefficient.abs(); + if !(low.is_finite() && high.is_finite()) || high < -slack { + return None; + } + Some(Self { + low: low.max(0.0), + high: high.max(0.0), + }) + } + + /// The sums both bands allow, or `None` where they do not meet. + fn overlap(self, other: Self) -> Option { + let (low, high) = (self.low.max(other.low), self.high.min(other.high)); + (low <= high).then_some(Self { low, high }) + } + + /// The sum of the band nearest to `sum`. + fn nearest(self, sum: f64) -> f64 { + sum.clamp(self.low, self.high) + } +} + +/// Just under half a price step: a level this far from a grid price still rounds to it, with +/// room for the float arithmetic the replay places it by. +const HALF_STEP_SHARE: f64 = 0.49; + +/// The sums the core's take carries: the take as placed against the same take before the +/// modifiers, `(take / base − 1) / SellModifier` for a long, `(base / take − 1) / SellModifier` +/// for a short, as `take_level` applies it. The base is the rule's at the fact's parameters — a +/// MoonHook's off the depth its take was placed at (`record::placed_hook_depth`). +/// +/// `None` without `SellModifier`, for a take the rule does not place (MoonShot's lift to the +/// ask carries no modifier; Spread's level is recorded, not computed; a hook without its detect +/// depth), and without a reading. +fn take_reading( + deal: &Deal, + params: &ExitParams, + exit_points: Option<&[(i64, f64)]>, +) -> Option { + let model = ExitModel::new(params); + if params.sell_modifier == 0.0 + || params.sell_at_last_price + || take_is_recorded(&deal.kind) + || !model.take_known(deal) + { + return None; + } + let take_closed = deal.sell_reason.trim().eq_ignore_ascii_case(REASON_TAKE); + let level = archived_take(exit_points).or_else(|| take_closed.then_some(deal.sell_price))?; + let long = deal.is_long(); + let base = level_off_buy(deal.buy_price, model.base_take_pct(deal).max(0.0), long); + if !(base.is_finite() && base > 0.0) { + return None; + } + Reading::of(deal, level, params.sell_modifier, |take| { + let shift_pct = if long { + take / base - 1.0 + } else { + base / take - 1.0 + } * 100.0; + shift_pct / params.sell_modifier + }) +} + +/// The sums the core's stop carries: `StopLoss − StopLossModifier · Σ` is the distance the +/// printed level stands at. `None` without `StopLossModifier`, without a stop, with a ladder +/// configured (its later steps print their own levels), and on a reason that is no stop or +/// prints no usable level. +fn stop_reading(deal: &Deal, params: &ExitParams) -> Option { + if params.stop_loss_modifier == 0.0 + || params.stop_loss_pct == 0.0 + || params.second_stop.is_some() + || params.third_stop.is_some() + || !reason_starts_with(deal.sell_reason.trim(), REASON_STOP) + { + return None; + } + let level = stated_stop_level(&deal.sell_reason)?; + let buy = deal.buy_price; + let long = deal.is_long(); + Reading::of(deal, level, params.stop_loss_modifier, |stop| { + // The stop's per cent off the buy, negative on the losing side; a short's divides. + let stop_pct = if long { + stop / buy - 1.0 + } else { + buy / stop - 1.0 + } * 100.0; + (params.stop_loss_pct - stop_pct) / params.stop_loss_modifier + }) +} + #[cfg(test)] mod tests; diff --git a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods/tests.rs index 882f0298..ffb73786 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods/tests.rs @@ -1,7 +1,9 @@ -//! The Delta Modifiers tab's sum as the core forms it (the core developer via LinKvo, 2026-09-24). +//! The Delta Modifiers tab's sum as the core forms it (the core developer via LinKvo, 2026-09-24), +//! and the core's own sum read back off its record. use super::*; use crate::db::tuner::ticks::exit::tests::deal; +use crate::db::tuner::ticks::hook::KIND_MOONHOOK; use crate::db::tuner::ticks::mshot::{MarketSign, Modifiers}; /// The market and the BTC deltas are read as magnitudes in the sell family; the corridor family @@ -44,3 +46,226 @@ fn the_sum_is_a_capped_magnitude() { }; assert!((modifier_sum(&capped, &d, d.buy_ms) - 2.0).abs() < 1e-9); } + +/// A long whose snapshot sums to 2 while the core placed its take off a sum of 3. +fn hook_long() -> (Deal, ExitParams) { + let mut d = deal(false); + d.kind = KIND_MOONHOOK.to_string(); + d.buy_price = 100.0; + d.tick = Some(0.0001); + d.hook_depth_pct = Some(2.0); + d.hook_stated_take_pct = Some(1.0); + d.deltas.d15m = 20.0; + d.sell_reason = REASON_TAKE.to_string(); + // 100 · 1.01 · (1 + 0.3 · 3 / 100) + d.sell_price = 101.0 * 1.009; + let p = ExitParams { + sell_modifier: 0.3, + hook_sell_level_pct: 50.0, + sell_mods: Modifiers { + add_15m: 0.1, + ..Modifiers::default() + }, + ..ExitParams::default() + }; + (d, p) +} + +/// The take a hook's untouched take closed at is the core's sum spent: read back, the fact's own +/// parameters sum to the core's number, not the snapshot's. +#[test] +fn a_take_closed_sale_gives_the_core_its_own_sum() { + let (mut d, p) = hook_long(); + assert!((modifier_sum(&p, &d, d.buy_ms) - 2.0).abs() < 1e-9); + d.fact_modifier = FactModifier::of(&d, &p, None); + assert!(d.fact_modifier.is_some()); + assert!((modifier_sum(&p, &d, d.buy_ms) - 3.0).abs() < 1e-3); +} + +/// The archived take wins over the sale: a sale after the line moved is not the take. +#[test] +fn the_archived_take_is_read_before_the_sale() { + let (mut d, p) = hook_long(); + d.sell_reason = "Auto Price Down".to_string(); + d.sell_price = 100.5; + // 100 · 1.01 · (1 + 0.3 · 4 / 100) + let line = [(d.buy_ms, 101.0 * 1.012), (d.buy_ms + 5_000, 100.8)]; + d.fact_modifier = FactModifier::of(&d, &p, Some(&line)); + assert!((modifier_sum(&p, &d, d.buy_ms) - 4.0).abs() < 1e-3); + // Without the line, a sale that is not the take's reads nothing. + assert_eq!(FactModifier::of(&d, &p, None), None); +} + +/// A short's take divides, and so does the reading back. +#[test] +fn a_short_reads_its_sum_through_the_division() { + let (mut d, p) = hook_long(); + d.is_short = true; + // 100 / 1.01 / (1 + 0.3 · 3 / 100) + d.sell_price = 100.0 / 1.01 / 1.009; + d.fact_modifier = FactModifier::of(&d, &p, None); + assert!((modifier_sum(&p, &d, d.buy_ms) - 3.0).abs() < 1e-3); +} + +/// The stop's printed level carries the same sum: `StopLoss − StopLossModifier · Σ`. +#[test] +fn a_printed_stop_gives_the_sum_where_no_take_does() { + let (mut d, mut p) = hook_long(); + p.sell_modifier = 0.0; + p.stop_loss_pct = -2.0; + p.stop_loss_modifier = 0.2; + // −2 − 0.2 · 3 = −2.6 % + d.sell_price = 97.0; + d.sell_reason = "StopLoss AutoActivated on price drop: BID = 97.3 (strategy ); \ + StopLoss fixed: 97.4000 Allow" + .to_string(); + d.fact_modifier = FactModifier::of(&d, &p, None); + assert!((modifier_sum(&p, &d, d.buy_ms) - 3.0).abs() < 1e-3); +} + +/// A variant's sum is the model's own moved by the fact's miss, scaled to its coefficients: +/// doubling every coefficient doubles the whole sum; zeroing them leaves none. +#[test] +fn a_variant_inherits_the_miss_scaled_to_its_coefficients() { + let (mut d, p) = hook_long(); + d.fact_modifier = FactModifier::of(&d, &p, None); + let doubled = ExitParams { + sell_mods: Modifiers { + add_15m: 0.2, + ..Modifiers::default() + }, + ..p.clone() + }; + assert!((modifier_sum(&doubled, &d, d.buy_ms) - 6.0).abs() < 1e-3); + let none = ExitParams { + sell_mods: Modifiers::default(), + ..p.clone() + }; + assert!(modifier_sum(&none, &d, d.buy_ms).abs() < 1e-9); + // A variant that only moves `SellModifier` keeps the core's sum. + let other_spend = ExitParams { + sell_modifier: 0.5, + ..p + }; + assert!((modifier_sum(&other_spend, &d, d.buy_ms) - 3.0).abs() < 1e-3); +} + +/// Nothing is read for a trade without an `Add*` term — the core's sum is zero — nor for a take +/// the rule does not place. +#[test] +fn no_reading_without_terms_or_a_rule_take() { + let (d, p) = hook_long(); + let bare = ExitParams { + sell_mods: Modifiers::default(), + ..p.clone() + }; + assert_eq!(FactModifier::of(&d, &bare, None), None); + let fixed = ExitParams { + hook_sell_fixed: true, + ..p + }; + assert_eq!(FactModifier::of(&d, &fixed, None), None); +} + +/// A level under the formula by a price step is the grid's rounding and reads zero; one far +/// under it is a level the formula does not explain and reads nothing. +#[test] +fn a_negative_reading_is_rounding_or_nothing() { + let (mut d, p) = hook_long(); + d.sell_price = 101.0 - 0.0001; + d.fact_modifier = FactModifier::of(&d, &p, None); + assert!(modifier_sum(&p, &d, d.buy_ms).abs() < 1e-9); + d.sell_price = 100.5; + assert_eq!(FactModifier::of(&d, &p, None), None); +} + +/// Coefficients of both signs never cancel the scale: the miss is spread over their magnitudes, +/// and a variant that zeroes every term still sums to nothing. +#[test] +fn mixed_signs_keep_a_scale() { + let (mut d, mut p) = hook_long(); + p.sell_mods.add_1h = -0.1; + d.deltas.d1h = 0.0; + d.fact_modifier = FactModifier::of(&d, &p, None); + assert!((modifier_sum(&p, &d, d.buy_ms) - 3.0).abs() < 1e-3); + let none = ExitParams { + sell_mods: Modifiers::default(), + ..p + }; + assert!(modifier_sum(&none, &d, d.buy_ms).abs() < 1e-9); +} + +/// A level that rounds to the record's price over a wide band of sums — a coarse step, a small +/// coefficient — keeps the model's own sum: the grid's rounding is never read as a sum. +#[test] +fn a_wide_band_keeps_the_models_sum() { + let (mut d, mut p) = hook_long(); + p.sell_modifier = 0.001; + // The model's sum of 2 moves the take by 0.002 %, well inside half of a 0.01 step. + d.sell_price = 101.0; + d.tick = Some(0.01); + d.fact_modifier = FactModifier::of(&d, &p, None); + assert!((modifier_sum(&p, &d, d.buy_ms) - 2.0).abs() < 1e-9); +} + +/// A trade whose take and stop both hold a reading keeps the sums both allow: a coarse step +/// leaves the take's band wide, and the stop's narrows it. +#[test] +fn the_take_and_the_stop_readings_overlap() { + let (mut d, p) = hook_long(); + // A step of 1 on a take of ~101.9: the take's band spans sums of about 1.7 to 4.9. + d.tick = Some(1.0); + d.sell_price = 102.0; + let take = take_reading(&d, &p, None).expect("a take band"); + assert!(take.low < 2.0 && take.high > 4.0); + // A stop printed at −2.6 % on a fine grid: Σ = 3 give or take a hair. + let fine = Deal { + tick: Some(0.01), + ..d.clone() + }; + let stop = Reading::of(&fine, 97.4, 0.2, |level| { + (-2.0 - (level / 100.0 - 1.0) * 100.0) / 0.2 + }) + .expect("a stop band"); + let both = take.overlap(stop).expect("the bands meet"); + assert!(both.low > 2.9 && both.high < 3.1); + // Bands that do not meet give nothing, and the take's is kept. + let apart = Reading { + low: 10.0, + high: 11.0, + }; + assert_eq!(take.overlap(apart), None); +} + +/// A sum read at `MaxModifier` says only that the core's reached the cap: the miss kept is what +/// lifts the model's sum to it, never the cap's cut. +#[test] +fn a_sum_read_at_the_cap_keeps_only_its_lower_bound() { + let (mut d, mut p) = hook_long(); + p.max_modifier = 2.9; + d.fact_modifier = FactModifier::of(&d, &p, None); + assert!((modifier_sum(&p, &d, d.buy_ms) - 2.9).abs() < 1e-9); + let uncapped = ExitParams { + max_modifier: 0.0, + ..p + }; + assert!((modifier_sum(&uncapped, &d, d.buy_ms) - 2.9).abs() < 1e-9); +} + +/// A hook's take is placed off the depth its stated take implies, not the comment's `Depth`, +/// which the core writes at the close. +#[test] +fn the_hook_depth_is_read_off_the_stated_take() { + use crate::db::tuner::ticks::record::placed_hook_depth; + let (mut d, p) = hook_long(); + d.hook_depth_pct = Some(2.0); + d.hook_stated_take_pct = Some(1.4); + assert!(placed_hook_depth(&d, &p).is_some_and(|depth| (depth - 2.8).abs() < 1e-9)); + let fixed = ExitParams { + hook_sell_fixed: true, + ..p.clone() + }; + assert_eq!(placed_hook_depth(&d, &fixed), None); + d.hook_stated_take_pct = None; + assert_eq!(placed_hook_depth(&d, &p), None); +} diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs index 759c1f34..80df0de7 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs @@ -106,7 +106,7 @@ impl ExitModel<'_> { /// A hook whose depth or level is unknown falls back to `SellPrice` so the line still has /// somewhere to step down from — and [`Self::take_known`] answers `false` for it, which is /// what keeps that fallback out of the verdict. - fn base_take_pct(&self, deal: &Deal) -> f64 { + pub(super) fn base_take_pct(&self, deal: &Deal) -> f64 { if deal.kind == KIND_MOONHOOK && self.params.hook_sell_level_pct > 0.0 { if let Some(depth) = deal.hook_depth_pct.filter(|d| d.is_finite() && *d > 0.0) { return hook_take_pct(depth, self.params.hook_sell_level_pct); diff --git a/crates/moon-core/src/db/tuner/ticks/exit/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/tests.rs index f22c82e3..37acddc4 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/tests.rs @@ -39,6 +39,7 @@ pub(super) fn deal(short: bool) -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + fact_modifier: None, hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, diff --git a/crates/moon-core/src/db/tuner/ticks/hook.rs b/crates/moon-core/src/db/tuner/ticks/hook.rs index a2335146..1556ce1e 100644 --- a/crates/moon-core/src/db/tuner/ticks/hook.rs +++ b/crates/moon-core/src/db/tuner/ticks/hook.rs @@ -19,8 +19,9 @@ //! that number on 90 of 118 trades within 0.01 pp, with the buy price as the base (median of //! fact against prediction +0.003 %). The remaining 28 all sit HIGHER than the formula, never //! lower; the depth in the comment is written at close time while the take was placed at fill -//! time, and the detect's state in between is not in the report. See -//! `docs-internal/STRATEGY_FORMULAS/moonhook.md`. +//! time, and the detect's state in between is not in the report. So the model runs the formula +//! on the depth the stated take implies (`record::placed_hook_depth`) wherever the comment +//! states one. See `docs-internal/STRATEGY_FORMULAS/moonhook.md`. //! //! The archive cannot stand in for this: the core files a line only when it was RE-PLACED, and //! a take that never moved has none — 0 of 155 take-closed trades on the same sample carry an @@ -34,10 +35,10 @@ pub const KIND_MOONHOOK: &str = "MoonHook"; pub struct HookDetect { /// `Depth: X%` — the detect's depth, per cent. The base of the take rule. pub depth_pct: f64, - /// `SellPrice: Y%` — the take the core actually placed, per cent from the buy. Not an - /// input of the model: it is the yardstick the formula is checked against, and the fact a - /// variant must NOT be judged by (a variant asks what another `HookSellLevel` would have - /// done, and the core's number answers only for the one it used). + /// `SellPrice: Y%` — the take the core actually placed, per cent from the buy, before the + /// delta modifiers. Never a variant's take (a variant asks what another `HookSellLevel` + /// would have done, and the core's number answers only for the one it used), but the depth + /// the formula runs on is read back off it (`record::placed_hook_depth`). pub stated_take_pct: Option, } diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index fd93ccae..6ebbd058 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -245,20 +245,28 @@ pub struct Deal { /// starts. FLOCK 2026-09-21 (HookN0, short): the model's `SellPrice` take at −1.0 %, the /// core's at −2.2 %, every PriceDown step then a different level. pub archived_take: Option, + /// The core's own delta-modifier sum on this trade, read back off its record — the take it + /// placed, else the stop level it printed — as what the model's deltas miss of it + /// ([`exit::delta_mods::FactModifier`]). Every sum the model forms goes through it + /// ([`exit::delta_mods::modifier_sum`]), the fact's and every variant's; filled with the rest + /// of the model inputs, `None` where the trade runs no `Add*` term or the record holds + /// neither reading. + pub fact_modifier: Option, /// The level the entry order stood at when the core created it — where a replay from the /// creation places the fact's order ([`record::entry_placement`]); filled with the rest of /// the model inputs, `None` before them and wherever the record does not prove it. pub entry_placed: Option, - /// The detect depth of a MoonHook trade, per cent, as the core wrote it into the report's - /// `comment` — the base of that kind's take rule ([`hook::hook_take_pct`]). `None` for - /// every other kind, and for a hook row whose comment the scan could not read. + /// The detect depth of a MoonHook trade, per cent — the base of that kind's take rule + /// ([`hook::hook_take_pct`]). The scan reads the `Depth` the core wrote into the report's + /// `comment`; the model inputs then replace it with the depth the take was placed off, where + /// the comment states that take ([`record::placed_hook_depth`]). `None` for every other kind, + /// and for a hook row whose comment the scan could not read. pub hook_depth_pct: Option, - /// The take the core actually placed on a hook trade, per cent from the buy, as the same - /// comment states it. Never an input of the model, and nothing asserts it: the ignored - /// `tests::real_data` harness prints it beside the formula's own number so a developer can - /// see the two drift apart on real trades. It could not stand in for the formula anyway — - /// a variant asks about a level the core never used, and this number answers only for the - /// one it did. + /// The take the core actually placed on a hook trade, per cent from the buy before the delta + /// modifiers, as the same comment states it. It does not stand in for the formula — a + /// variant asks about a level the core never used — but it fixes the depth the formula runs + /// on ([`record::placed_hook_depth`]), and through it every variant's `HookSellLevel` scales + /// from the core's own take. pub hook_stated_take_pct: Option, /// How much later than `PriceDownDelay` the deal's CORE makes the next PriceDown step after /// one that moved the order, milliseconds — its replace round trip, calibrated by the caller diff --git a/crates/moon-core/src/db/tuner/ticks/record.rs b/crates/moon-core/src/db/tuner/ticks/record.rs index 9b07bd85..add51bf5 100644 --- a/crates/moon-core/src/db/tuner/ticks/record.rs +++ b/crates/moon-core/src/db/tuner/ticks/record.rs @@ -17,10 +17,12 @@ //! proxy, and the verdict (which replays the proxy, never the anchor) is what says how far the //! proxy may be trusted. +use super::exit::delta_mods::FactModifier; use super::exit::sell_order::{archived_pre_spike_ask, archived_take}; use super::exit::stops::stop_pct; use super::exit::{ExitParams, StopStep}; use super::gap::TapeGap; +use super::hook::KIND_MOONHOOK; use super::verify::{ POINT_TIME_TOLERANCE_MS, Verdict, archived_stop_jump, is_stop_reason, stop_jump_level, }; @@ -120,7 +122,9 @@ pub struct OwnLines<'a> { } /// Fill the model inputs the core's own record gives: the ask a MoonShot's take was lifted to, -/// read back off the take as placed, the take itself, the level the entry order was placed at +/// read back off the take as placed, the take itself, the depth a MoonHook's take was placed +/// off ([`placed_hook_depth`]), the core's delta-modifier sum ([`FactModifier`]), the level the +/// entry order was placed at /// ([`entry_placement`]), the stop anchor, the entry settings the trade ran with /// ([`Deal::own_entry`]) and the hole of a long position's tape ([`Deal::gap`]). /// @@ -139,6 +143,12 @@ pub fn prepare_deal( ) { deal.pre_spike_ask = archived_pre_spike_ask(lines.exit, exit, deal.is_short); deal.archived_take = archived_take(lines.exit); + // Before the core's sum, which is read against the take this depth places. + if let Some(depth) = placed_hook_depth(deal, exit) { + deal.hook_depth_pct = Some(depth); + } + // Before the stop anchor: the fact's stop distance is spent from this sum. + deal.fact_modifier = FactModifier::of(deal, exit, lines.exit); deal.entry_placed = entry_placement(deal, lines); deal.stop_anchor = Some(StopAnchor::of(deal, exit, lines.exit)); deal.own_entry = Some(entry.clone()); @@ -178,9 +188,37 @@ pub fn entry_placement(deal: &Deal, lines: OwnLines<'_>) -> Option { (level.is_finite() && level > 0.0).then_some(level) } +/// The depth a MoonHook trade's take was placed off ([`Deal::hook_depth_pct`]): the take the +/// core states in the comment (`SellPrice: Y%`, before the delta modifiers) over the trade's own +/// `HookSellLevel`, so the rule places the core's take at the fact's parameters and a variant's +/// level scales from it. The comment's `Depth` is written at the close, while the take was placed +/// at the fill: on the take-closed hooks of 2026-09-25 whose strategy runs no `SellModifier`, the +/// sale sat on the stated take to its hundredth (GEOD 7.467 % against 7.47 %, W3GG 28.567 against +/// 28.56), a tenth to a half above `Depth · HookSellLevel` (5.55, 19.64). +/// +/// `None` — the comment's depth stays — for every other kind, without a stated take, and where +/// the take is not `HookSellLevel` of the depth (`HookSellFixed`, a level of zero). +pub fn placed_hook_depth(deal: &Deal, exit: &ExitParams) -> Option { + if deal.kind != KIND_MOONHOOK || exit.hook_sell_fixed || exit.hook_sell_level_pct <= 0.0 { + return None; + } + let stated = deal + .hook_stated_take_pct + .filter(|pct| pct.is_finite() && *pct > 0.0)?; + Some(stated * 100.0 / exit.hook_sell_level_pct) +} + /// The deal as the verdict replays it: without what the fact proves ([`Deal::stop_anchor`], /// [`Deal::own_entry`]) — the verdict exists to test the model, and a model handed the fact /// passes by construction. +/// +/// What stays are the inputs the model cannot rebuild and reads off the record as the core's +/// own numbers, exactly as every variant reads them: the archived take and pre-spike ask, the +/// placed hook depth, the core's delta-modifier sum ([`Deal::fact_modifier`]). Each fixes a LEVEL +/// the core placed; the verdict then judges what the rules did from it — which rule reached the +/// tape first, and when. Where the sum was read off the very level being judged — a take-closed +/// sale, or a stop printing its own level with no take on record — that level agrees by +/// construction, and the verdict judges the order and the moment alone. pub fn unanchored(deal: &Deal) -> Deal { Deal { stop_anchor: None, diff --git a/crates/moon-core/src/db/tuner/ticks/record/tests.rs b/crates/moon-core/src/db/tuner/ticks/record/tests.rs index 135a4086..8a439081 100644 --- a/crates/moon-core/src/db/tuner/ticks/record/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -50,6 +50,7 @@ fn stopped() -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + fact_modifier: None, hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 5a17557e..12151788 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -39,6 +39,7 @@ pub(super) fn prepared(uid: i64, peak: f64) -> PreparedDeal { tick: None, pre_spike_ask: None, archived_take: None, + fact_modifier: None, hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, diff --git a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs index 02254e19..fee6d73c 100644 --- a/crates/moon-core/src/db/tuner/ticks/stats/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -22,6 +22,7 @@ fn deal(pnl: f64, spent: f64) -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + fact_modifier: None, hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 1e2056b2..e258921f 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -49,6 +49,7 @@ pub(super) fn deal() -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + fact_modifier: None, hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, @@ -1560,6 +1561,7 @@ fn a_hook_without_its_depth_is_not_a_known_take() { // a variant of a hook whose depth is unknown has nowhere to put its take. assert!(!model.take_known(&Deal { archived_take: Some(101.0), + fact_modifier: None, ..no_depth })); } @@ -1571,6 +1573,7 @@ fn a_spread_takes_the_level_its_core_recorded() { let spread = Deal { kind: "Spread".into(), archived_take: Some(102.3), + fact_modifier: None, ..deal() }; let far = ExitParams { @@ -1586,6 +1589,7 @@ fn a_spread_takes_the_level_its_core_recorded() { assert!((model.take_level(&spread, &[], fill) - 102.3).abs() < 1e-9); let unrecorded = Deal { archived_take: None, + fact_modifier: None, ..spread }; assert!(!model.take_known(&unrecorded)); diff --git a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs index 01daddd3..c3910ad8 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -87,7 +87,8 @@ fn held_ticks(exchange_key: &str, market: &str, spans: &Coverage) -> (Vec, /// One deal as the verdict saw it, for an analysis outside the probe: a JSON line in /// `/deals.jsonl` (the row, the strategy's raw values, the held walk's points, the archived /// Entry and Exit lines, the entry order's creation, placement and saved corridor, the MoonShot -/// bounds) and its prints as `t,price,qty,side` in `/ticks/.csv`. +/// bounds, the model's own take and the delta-modifier sum around the fill) and its prints as +/// `t,price,qty,side` in `/ticks/.csv`. #[allow(clippy::too_many_arguments)] fn dump_deal( dir: &str, @@ -111,6 +112,17 @@ fn dump_deal( .and_then(|jump_at| verify::archived_stop_jump(deal, jump_at, exit_points)); let dir = PathBuf::from(dir); let _ = std::fs::create_dir_all(dir.join("ticks")); + let sum_around_fill: Vec<(i64, f64)> = [ + -60_000i64, -10_000, -2_000, -500, 0, 250, 500, 1_000, 2_000, 5_000, + ] + .iter() + .map(|dt| { + ( + *dt, + super::super::exit::delta_mods::modifier_sum(exit, deal, deal.buy_ms + dt), + ) + }) + .collect(); let row = serde_json::json!({ "uid": deal.report_uid, "core": deal.core_name, @@ -133,6 +145,27 @@ fn dump_deal( "order_open_ms": deal.order_open_ms(), "entry_placed": deal.entry_placed, "corridor": deal.corridor, + // The take the rules place off the fact's fill, modifiers and all, beside what they are + // built from — the check against the archive's first point outside the probe. + "model_take": ExitModel::new(exit).take_level( + deal, + ticks, + Fill { + t_ms: deal.buy_ms, + price: deal.buy_price, + }, + ), + "modifier_sum": super::super::exit::delta_mods::modifier_sum(exit, deal, deal.buy_ms), + // The same sum around the fill, for when the core actually reads the deltas: offsets in + // ms from the fill. `tracked` says only that the deal HAS a live track — outside its + // covered stretches the track answers with the snapshot, so an offset may still read it. + "modifier_sum_at": sum_around_fill, + "tracked": deal.delta_track.is_some(), + // Whether the record held a reading of the core's sum — the sum above is then inside the + // band that reading allows, the model's own where it already was. + "fact_modifier": deal.fact_modifier.is_some(), + "hook_depth": deal.hook_depth_pct, + "hook_stated": deal.hook_stated_take_pct, "stop": { "pct": stop, "level": level, @@ -569,23 +602,36 @@ fn real_data_reproduction() { ); // Every (exchange, market) pair the tape holds — the deal's market spelling is not in the - // report, so a coin is tried under each market of its core's exchange that stores it. - let spans_db = - Connection::open_with_flags(paths::trades_db_path(), OpenFlags::SQLITE_OPEN_READ_ONLY) - .expect("trades.sqlite"); - let pairs: Vec<(String, String)> = spans_db - .prepare("SELECT DISTINCT exchange, market FROM spans") - .expect("spans") - .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) - .expect("pairs") - .flatten() - .collect(); + // report, so a coin is tried under each market of its core's exchange that stores it. Asked + // of the cache's own worker through the same handle `query_held` reads the prints through + // (`handle`, which honours `persist_trades`), so the list covers whatever tables the file + // keeps them in (legacy `spans` and packed `packs`) and opens nothing the read would not. + let pairs: Vec<(String, String)> = match crate::market::trade_replay::trade_cache::handle() + .and_then(|cache| cache.inventory()) + { + Some(Ok(inventory)) => inventory.keys, + other => { + eprintln!("trades.sqlite gave no market list ({other:?}); nothing to replay"); + return; + } + }; + eprintln!("markets with a held tape: {}", pairs.len()); let margin_ms = crate::market::trade_replay::margin_ms(); let venue_of_core = core_venues(); eprintln!("core venues (from the identity lines of the logs): {venue_of_core:?}"); let keys = param_keys(); - let defaults = HashMap::new(); + // `MOON_TICKS_DEFAULTS=selldelay=0,mshotsellatlastprice=1`: the strategy-field defaults the + // app reads off the live schema (`strategy_field_defaults`), which a data root does not keep. + let defaults: HashMap = std::env::var("MOON_TICKS_DEFAULTS") + .map(|spec| { + spec.split(',') + .filter_map(|pair| pair.split_once('=')) + .filter_map(|(k, v)| Some((k.trim().to_ascii_lowercase(), v.trim().parse().ok()?))) + .collect() + }) + .unwrap_or_default(); + eprintln!("strategy-field defaults: {defaults:?}"); let core_lags = core_step_lags(&read.deals, &keys, &defaults); // `MOON_TICKS_LATENCY_BASE_MS=`: each core's latency is that plus its own archived // replace round trip, instead of one number for every core. @@ -1041,8 +1087,9 @@ fn real_data_reproduction() { tick = deal.tick, hook_depth = round3(deal.hook_depth_pct), hook_stated = round3(deal.hook_stated_take_pct), - // The formula against the core's own number, for the same trade — the check that - // keeps `docs-internal/STRATEGY_FORMULAS/moonhook.md` honest over time. + // The formula against the core's own number, for the same trade. The depth is the one + // the stated take implies (`record::placed_hook_depth`), so the two agree wherever + // the comment states a take; a gap left is a row without one. hook_model = round3( deal.hook_depth_pct .map(|d| super::super::hook::hook_take_pct(d, exit.hook_sell_level_pct)) diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index a3f79052..ac01e598 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -70,7 +70,10 @@ pub const BOOK_STOP_TIME_TOLERANCE_MS: i64 = 2_300; /// Tolerance on a STOP's level: the modelled level against the one the core fixed carries /// `StopLossModifier` over deltas the model only partly re-reads live — the coin's ranges where /// the deal has a track, the BTC, market, mark and price-bug terms as the report's one snapshot -/// (`exit::delta_mods::modifier_sum`) — and the residual sits right there. +/// (`exit::delta_mods::modifier_sum`) — and the residual sits right there wherever the record +/// did not keep the core's own sum (`Deal::fact_modifier`). Where it did, the sum is the one +/// that places the recorded level to the price step, and a stop read off the take's band carries +/// that band's width times `StopLossModifier / SellModifier`. pub const STOP_PRICE_TOLERANCE: f64 = 0.003; /// How much BETTER than the modelled level the fact's fill may be and still be that level's @@ -493,7 +496,8 @@ fn is_fill_point(deal: &Deal, exit: &ExitParams, last: (i64, f64), prev: (i64, f /// /// The level tolerance is [`STOP_PRICE_TOLERANCE`] rather than the line's: the modelled level /// carries `StopLossModifier` over deltas the model only partly re-reads live (see -/// `exit::delta_mods::modifier_sum`), and the residual sits right there. +/// `exit::delta_mods::modifier_sum`), and the residual sits right there where the record kept +/// no sum of the core's own. /// /// Archived moves from the activation on — the first move past the level [`stop_jump_level`] /// gives: the stop's, or for a trailing stop's reason its own line under the printed peak — are diff --git a/crates/moon-core/src/market/source/read.rs b/crates/moon-core/src/market/source/read.rs index 3dc15509..7df82fae 100644 --- a/crates/moon-core/src/market/source/read.rs +++ b/crates/moon-core/src/market/source/read.rs @@ -518,12 +518,14 @@ impl MarketDataSource { inner.clients.get(&core).and_then(SharedMoonClient::get) } - /// Return the market price step from MoonProto's `chart_price_step`. - /// - /// It is the market's own tick, used where a price difference has to be judged against what the - /// exchange can actually express — the sells-to-rectangle band, for one. `None` means the - /// provider, snapshot, or market is unavailable, or the step is non-positive; callers then fall - /// back to their own rule rather than inventing a step. + /// Return MoonProto's `chart_price_step` for a market: the CHART's aggregation step, not the + /// exchange's tick. moonproto derives it from the ask as `max(eps, ask / 5000)` (Delphi + /// `AddNewAksPrice`), so it is off the market's price grid — COOL at ~0.0019 reads + /// 0.00000038 against a tick of 0.000001 (2026-09-26). It suits what the chart groups by (the + /// HVol bins); a caller that needs the price grid itself — a level an order can stand at — + /// must not read it as one. `None` means the provider, snapshot, or market is unavailable, or + /// the step is non-positive; callers then fall back to their own rule rather than inventing a + /// step. pub fn price_step(&self, core: CoreId, market: &str) -> Option { let client = { let inner = self.inner.read().expect("market source poisoned"); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs index d36e006d..c360b430 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs @@ -27,6 +27,7 @@ fn row(tape: TapeStatus) -> DealRow { tick: None, pre_spike_ask: None, archived_take: None, + fact_modifier: None, hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index 11a23c99..f499a762 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -85,14 +85,12 @@ impl FetchResolver { let market = self .source .resolve_market(deal.core_uid, quote, &deal.coin)?; - let tick = self.source.price_step(deal.core_uid, &market); Some(Arc::new(RowAddress { core_uid: deal.core_uid, venue: address.venue, exchange_key: address.exchange_key, market, btc_market, - tick, })) }); self.addresses.insert(key, address.clone()); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index 18d23ec9..d6d2072d 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -512,6 +512,7 @@ impl AnalyticsView { } } } + log_replay(&rows); // Rows the job answered while the batch was read: read again, each behind // whatever walk is running. A row the job answers during THIS loop is kept // covered by the fold (`update_rows`), not re-read once more. @@ -894,7 +895,11 @@ pub(super) fn replay_row_with( return; } row.tape = TapeStatus::Covered; - row.deal.tick = address.tick.or_else(|| infer_tick(&ticks)); + // The price grid off the prints themselves, as the `real_data` bench reads it: the live + // catalog has no tick to give — its `price_step` is the chart's `ask / 5000`, off the grid, and + // rounding every level to it moved the lines off the core's on every row (2026-09-26: the + // axis ✓ 65 % where the bench on the same trades read 92 %). + row.deal.tick = infer_tick(&ticks); let keys = params::param_keys(); let values = strategy_values_at( row.deal.strategy_id, @@ -955,6 +960,34 @@ pub(super) fn replay_row_with( row.ticks = Some(tape::PackedTape::pack(ticks)); } +/// One line per load on what its replay came to — the rows, those it covered, the exit share and +/// each core's step lag — to hold against the `real_data` bench over the same trades. Read off +/// the replayed rows alone: no second read of the database. +fn log_replay(rows: &[DealRow]) { + let mut lags: Vec<(&str, f64)> = rows + .iter() + .map(|r| (r.deal.core_name.as_str(), r.deal.step_lag_ms)) + .collect(); + lags.sort_by(|a, b| a.0.cmp(b.0)); + lags.dedup_by(|a, b| a.0 == b.0); + let covered = rows + .iter() + .filter(|r| r.tape == TapeStatus::Covered) + .count(); + let exit = verify::share( + rows.iter() + .filter_map(|r| r.verdict.as_ref()) + .map(|v| v.exit), + ); + log::info!( + target: moon_core::diagnostics::TICKS_AXIS_TARGET, + "[x] ticks replay: {} row(s), {covered} covered, exit ✓ {}/{} · step lag {lags:?}", + rows.len(), + exit.0, + exit.1, + ); +} + /// One line per load on what the table holds of its tape — the numbers the memory budget /// is judged by, read from the log instead of guessed. fn log_tape_budget(data: &TicksData) { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 72e8afd2..0a957cba 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -25,6 +25,7 @@ fn deal(uid: i64, buy_ms: i64, buy: f64, sell: f64, short: bool) -> Deal { tick: None, pre_spike_ask: None, archived_take: None, + fact_modifier: None, hook_depth_pct: None, hook_stated_take_pct: None, step_lag_ms: 0.0, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index c1a2a87c..b2ca900e 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -93,9 +93,10 @@ impl DealRow { /// Take everything a replay learned about this row from its answer: the tape's word, the /// verdict, the model inputs derived for the deal (the price step, the archived pre-spike - /// ask and take, where the entry order was placed, the core's step lag, what the fact proves - /// about the stop, the entry the trade ran with, the live deltas), the prints, the entry line - /// and the held coverage. + /// ask and take, the placed hook depth, the core's own delta-modifier sum, where the entry + /// order was placed, the + /// core's step lag, what the fact proves about the stop, the entry the trade ran with, the + /// tape's hole, the live deltas), the prints, the entry line and the held coverage. /// Every fold of a replay answer goes through here: the variants replay the STORED row /// (`prepared_deals`), so a take lifted to the archive's pre-spike ask in the verdict but /// read off the tape in the variants puts the two on different levels, and a row folded @@ -107,10 +108,13 @@ impl DealRow { self.deal.tick = answer.deal.tick; self.deal.pre_spike_ask = answer.deal.pre_spike_ask; self.deal.archived_take = answer.deal.archived_take; + self.deal.hook_depth_pct = answer.deal.hook_depth_pct; + self.deal.fact_modifier = answer.deal.fact_modifier; self.deal.entry_placed = answer.deal.entry_placed; self.deal.step_lag_ms = answer.deal.step_lag_ms; self.deal.stop_anchor = answer.deal.stop_anchor; self.deal.own_entry = answer.deal.own_entry; + self.deal.gap = answer.deal.gap; self.deal.delta_track = answer.deal.delta_track; self.deal.bars = answer.deal.bars; self.ticks = answer.ticks; @@ -130,8 +134,6 @@ pub(in crate::analytics::tuner) struct RowAddress { /// BTC's market on the same exchange, as the catalog spells it — the BTC deltas are read off /// its bars; `None` when the catalog names none, and those deltas keep the snapshot. pub(in crate::analytics::tuner) btc_market: Option, - /// The market's price step from the live catalog, when the core reports it. - pub(in crate::analytics::tuner) tick: Option, } /// One "now" cell of the parameter grid over the selected strategies. From 4f9208280517a550074e8604cec74cf91e1fa5d8 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sat, 26 Sep 2026 12:46:19 +0200 Subject: [PATCH 46/51] fix(tuner): a searched value moves off the strategy as a value, not as text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/moon-core/src/db/tuner/ticks/search.rs | 18 +++++++++++---- .../src/db/tuner/ticks/search/tests.rs | 23 +++++++++++++++++++ .../src/db/tuner/ticks/unmodelled.rs | 2 +- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index bd312d59..cb5e1ee6 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -46,6 +46,7 @@ use super::params::{ ParamGroup, ParamKind, StrategyValues, TICK_PARAMS, TickParam, exit_params, mshot_params, }; use super::settings::ModelSettings; +use super::unmodelled::same_value; use super::{Deal, Deltas, EntryParams, ExitModel, ExitParams, Outcome, entry_model_for, simulate}; use crate::db::metrics::Tally; use crate::db::tuner::threshold_search::search::{install, restart_seed}; @@ -524,11 +525,20 @@ impl<'a> Bases<'a> { .collect() } - /// Whether `value` of `key` is something at least one base, under `held`, does not hold. + /// Whether `value` of `key` is something at least one base, under `held`, does not hold — as + /// a value, not as text: the search spells its points itself (`1`), a strategy as the core + /// wrote it (`1.0`), and the two are one value (PriceDownTimer on HookTest01, 2026-09-26, + /// landed in В1 as a change of `1.0` to `1`). A base leaving the field out — or holding it + /// blank, which `same_value` would read as a boolean `false` — moves on any value: a value the + /// search completed for a switch it turned on (`deps`) is written with it even where it + /// equals the schema's default. fn moves(&self, held: &HashMap, key: &str, value: &str) -> bool { - self.owns - .iter() - .any(|own| held.get(key).or_else(|| own.get(key)).map(String::as_str) != Some(value)) + self.owns.iter().any(|own| { + held.get(key) + .or_else(|| own.get(key)) + .filter(|base| !base.trim().is_empty()) + .is_none_or(|base| !same_value(base, value)) + }) } } diff --git a/crates/moon-core/src/db/tuner/ticks/search/tests.rs b/crates/moon-core/src/db/tuner/ticks/search/tests.rs index 12151788..4d3a29a6 100644 --- a/crates/moon-core/src/db/tuner/ticks/search/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -818,3 +818,26 @@ fn a_search_that_no_point_can_keep_the_corridor_of_says_so() { let result = suggest(&deals, ¶ms, &SearchHandle::new()); assert!(!matches!(result, Err(SearchMiss::Corridor)), "{result:?}"); } + +/// A value is compared as a value: a strategy's `1.0` and the search's `1` are one, so the field +/// does not land in В1 as a change. Another value is one, and so is any value of a field the +/// strategy leaves out — a switch's completed value is written with it. +#[test] +fn a_value_moves_as_a_value_not_as_text() { + let own: HashMap = [("PriceDownTimer".to_string(), "1.0".to_string())].into(); + let bases = Bases { + owns: vec![&own], + of_deal: vec![0], + }; + let held = HashMap::new(); + assert!(!bases.moves(&held, "PriceDownTimer", "1")); + assert!(bases.moves(&held, "PriceDownTimer", "2")); + assert!(bases.moves(&held, "TakeProfit", "1")); + // A blank base is no value: `0` is a change of it, not a spelling of it. + let blank: HashMap = [("UseTrailing".to_string(), String::new())].into(); + let bases = Bases { + owns: vec![&blank], + of_deal: vec![0], + }; + assert!(bases.moves(&held, "UseTrailing", "0")); +} diff --git a/crates/moon-core/src/db/tuner/ticks/unmodelled.rs b/crates/moon-core/src/db/tuner/ticks/unmodelled.rs index 7143a53f..cb5e58ff 100644 --- a/crates/moon-core/src/db/tuner/ticks/unmodelled.rs +++ b/crates/moon-core/src/db/tuner/ticks/unmodelled.rs @@ -320,7 +320,7 @@ fn also_holds( /// Whether two spellings of a field's value are the same value: as booleans when both are one /// (`YES`, `1`, `True`…), as numbers when both are (`0.0` and `0`), else as text. -fn same_value(a: &str, b: &str) -> bool { +pub(super) fn same_value(a: &str, b: &str) -> bool { if let (Some(x), Some(y)) = (as_bool(a), as_bool(b)) { return x == y; } From 6a42a8eff1d2f032ffd80b5b5acec19db0b1cfc8 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sat, 26 Sep 2026 17:25:34 +0200 Subject: [PATCH 47/51] fix(tuner): read the pre-spike ask off the last taker buy 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. --- .../src/db/tuner/ticks/exit/sell_order.rs | 46 ++++++++++---- crates/moon-core/src/db/tuner/ticks/mod.rs | 9 +-- crates/moon-core/src/db/tuner/ticks/tests.rs | 60 +++++++++++++++++++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs index 80df0de7..08b376fa 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs @@ -8,9 +8,9 @@ //! Delta-Modifier family moves it ([`super::delta_mods`]). It is raised by //! `MShotSellAtLastPrice` to the pre-spike price less `MShotSellPriceAdjust` (the FAQ: "the //! 4-second-old ASK, i.e. before the spike"; the model takes the ask the caller recovered from -//! the order archive (`Deal::pre_spike_ask`), else reads the last print at least +//! the order archive (`Deal::pre_spike_ask`), else reads the last taker buy at least //! `ModelSettings::pre_spike_lookback_ms` ([`crate::db::tuner::ticks::mshot::PRE_SPIKE_LOOKBACK_MS`] -//! by default) before the fill, since the tape has no book). +//! by default) before the fill, since the tape has no book — [`pre_spike_price`]). //! - **SellDelay** — milliseconds after the fill before the take is placed ([`armed_at`]). //! //! From the Moonbot FAQ (`PriceDown*`, `SellLevel*` answers) and the live strategies @@ -41,7 +41,7 @@ use crate::feed::types::Tick; impl ExitModel<'_> { /// The take-profit level for a fill: `SellPrice` off the fill, lifted to the pre-spike - /// ask (the archive's, else the tape's last print) less the adjustment when + /// ask (the archive's, else the tape's last taker buy, [`pre_spike_price`]) less the adjustment when /// `MShotSellAtLastPrice` is on. Long above, short below. pub fn take_level(&self, deal: &Deal, ticks: &[Tick], fill: Fill) -> f64 { // A kind whose take rule the model does not have starts where the core's line did — @@ -129,7 +129,9 @@ impl ExitModel<'_> { /// - a MoonShot lifted to the pre-spike ask (`MShotSellAtLastPrice`) needs that ask off the /// core's record (`Deal::pre_spike_ask`) — the tape's print before the spike sits 0.1–0.5 % /// under the book's ask on a dump, and on 88 stopped MoonShot trades (2026-09-23) a take - /// placed off it was touched before the core's stop on 30, turning a loss into a win; + /// placed off it was touched before the core's stop on 30, turning a loss into a win; the + /// taker buy it reads now lands within 0.05 % of the archived ask on under half the trades + /// ([`pre_spike_price`]), still no ground for a verdict; /// - every other kind takes `SellPrice`, known by construction. /// /// `false` is not "the model was wrong": it is "this trade's take is not modelled here", and @@ -228,14 +230,38 @@ pub fn archived_pre_spike_ask( (take.is_finite() && take > 0.0).then_some(take / factor) } -/// The last print at least `lookback_ms` ([`crate::db::tuner::ticks::mshot::PRE_SPIKE_LOOKBACK_MS`] -/// by default) before `at_ms` — the FAQ's "price before the spike". +/// How far before the cutoff [`pre_spike_price`] still takes a taker buy for the ask. On a tape of +/// sells alone the last buy can lie minutes back (the longest over the sample of 2026-09-26: 177 s, +/// 1 % past 49 s) — another market, not the ask before the spike. A minute costs nothing on that +/// sample; ten seconds already cost 1–3 points. +pub const PRE_SPIKE_BUY_WINDOW_MS: i64 = 60_000; + +/// The tape's reading of the FAQ's "4-second-old ASK": the last taker BUY at least `lookback_ms` +/// ([`crate::db::tuner::ticks::mshot::PRE_SPIKE_LOOKBACK_MS`] by default) before `at_ms` and at +/// most [`PRE_SPIKE_BUY_WINDOW_MS`] before that — a taker buy prints at the ask — else the last +/// print of either side by then. The ask for a short too: the core lifts a short's take off the +/// ASK as well. +/// +/// Measured against the ask the archive gives back (`archived_pre_spike_ask`) on 1 099 MoonShot +/// trades (2026-09-26), at 4 s: within 0.05 % on 46 % of the longs and 48 % of the shorts, where +/// the last print of either side — the reading before — landed on 38 % and 36 %, half a spread +/// under the ask (median −0.04 % long, −0.07 % short). On Binance's cores 56 %; on Gate's no +/// reading of the prints reaches 20 % — too thin a tape under too wide a spread; the side is not +/// what fails there (Gate futures infer it from the sign of `size`): the last taker sell lands +/// farther from the ask than the last buy on Gate too. Hence a take lifted off this reading is +/// never judged ([`ExitModel::take_known`]). pub fn pre_spike_price(ticks: &[Tick], at_ms: i64, lookback_ms: i64) -> Option { let cutoff = at_ms - lookback_ms; - ticks - .iter() - .rev() - .find(|t| (t.time_ms as i64) <= cutoff && t.price > 0.0) + let before = || { + ticks + .iter() + .rev() + .filter(move |t| (t.time_ms as i64) <= cutoff && t.price > 0.0) + }; + before() + .take_while(|t| cutoff - (t.time_ms as i64) <= PRE_SPIKE_BUY_WINDOW_MS) + .find(|t| t.side == crate::feed::types::Side::Buy) + .or_else(|| before().next()) .map(|t| f64::from(t.price)) } diff --git a/crates/moon-core/src/db/tuner/ticks/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index 6ebbd058..74b515ed 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -233,10 +233,11 @@ pub struct Deal { /// The book's ASK the core read `MShotSellAtLastPrice` off — "the 4-second-old ASK, before /// the spike" — when the caller could recover it: the archived Exit line's first point is /// the take as placed, and dividing out the trade's own `MShotSellPriceAdjust` gives the - /// ask back. `None` leaves the model to its own reading of the tape (the last print at - /// least [`mshot::PRE_SPIKE_LOOKBACK_MS`] before the fill), which sits below the ask on a - /// dump by 0.1–0.5 % (B2/CELR 2026-09-20, GSTOCKBSC 2026-09-21) and shifts every level - /// the sell line then steps down from. + /// ask back. `None` leaves the model to its own reading of the tape (the last taker buy at + /// least [`mshot::PRE_SPIKE_LOOKBACK_MS`] before the fill, `exit::sell_order::pre_spike_price`), + /// within 0.05 % of the ask on under half the trades — the last print of either side it read + /// before sat below the ask on a dump by 0.1–0.5 % (B2/CELR 2026-09-20, GSTOCKBSC 2026-09-21) + /// — and that shifts every level the sell line then steps down from. pub pre_spike_ask: Option, /// The take as the core placed it — the archived Exit line's first point — when the /// archive holds it. The one take rule the model has is MoonShot's (`SellPrice` lifted by diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index e258921f..09825834 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -860,6 +860,66 @@ fn the_pre_spike_price_is_the_last_print_at_least_four_seconds_back() { ); } +#[test] +fn the_pre_spike_price_reads_the_last_taker_buy_else_any_print() { + // A taker sell prints at the bid, half a spread under the ask the core read. + let ticks = vec![ + tick(0, 100.25, Side::Buy), + tick(500, 100.0, Side::Sell), + tick(4_500, 95.0, Side::Sell), + ]; + let at = 500 + PRE_SPIKE_LOOKBACK_MS; + assert_eq!( + pre_spike_price(&ticks, at, PRE_SPIKE_LOOKBACK_MS), + Some(100.25) + ); + // No buy by the cutoff: the last print of either side. + let sells = vec![tick(0, 100.0, Side::Sell), tick(500, 99.875, Side::Sell)]; + assert_eq!( + pre_spike_price(&sells, at, PRE_SPIKE_LOOKBACK_MS), + Some(99.875) + ); + // A buy from before the window is another market: the last print again. + let window = super::exit::sell_order::PRE_SPIKE_BUY_WINDOW_MS; + let stale = vec![ + tick(0, 120.0, Side::Buy), + tick(window + 1, 99.875, Side::Sell), + ]; + let at = window + 1 + PRE_SPIKE_LOOKBACK_MS; + assert_eq!( + pre_spike_price(&stale, at, PRE_SPIKE_LOOKBACK_MS), + Some(99.875) + ); +} + +#[test] +fn a_short_take_lifted_off_the_tape_reads_the_taker_buy_not_the_sell_under_it() { + // A short sells into a spike at 101; SellPrice 1 % puts the take at 101 / 1.01 = 100. The + // ask before the spike, 99.5, is farther down: the take sits there — not on the taker sell + // at 99, half a spread under the ask. + let deal = Deal { + is_short: true, + ..deal() + }; + let ticks = vec![ + tick(0, 99.5, Side::Buy), + tick(500, 99.0, Side::Sell), + tick(5_000, 101.0, Side::Buy), + ]; + let fill = Fill { + t_ms: 5_000, + price: 101.0, + }; + let exit = ExitParams { + sell_price_pct: 1.0, + sell_at_last_price: true, + sell_price_adjust_pct: 0.0, + ..ExitParams::default() + }; + let take = ExitModel::new(&exit).take_level(&deal, &ticks, fill); + assert!((take - 99.5).abs() < 1e-9, "{take}"); +} + #[test] fn a_take_the_tape_never_reaches_leaves_the_position_open() { let fill = Fill { From 95ebef0459bca7057ba9100d4a37f5bdf4b7f084 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sat, 26 Sep 2026 17:25:34 +0200 Subject: [PATCH 48/51] docs(tuner): code comments caught up with the model 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. --- crates/moon-core/src/db/tuner/ticks/exit.rs | 8 ++++---- crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs | 4 ++-- crates/moon-core/src/db/tuner/ticks/verify.rs | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs index 816c154b..9b55751d 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -157,14 +157,14 @@ pub struct ExitParams { pub sell_modifier: f64, /// `MaxModifier` — ceiling on the summed modifiers BEFORE the coefficient: /// `Min(MaxModifier, |Σ Pn · Dn|)` — the core caps the sum's magnitude, so the sum is never - /// negative (`exit::delta_mods::modifier_sum`). 0 means no ceiling. Live strategies keep it - /// around 70 % (median of 526 that set it), so it rarely binds. The same field caps the + /// negative (`exit::delta_mods::modifier_sum`). 0 means no ceiling. Set on 127 of 1 423 live + /// strategies (2026-09-25), 10…1000, so it rarely binds. The same field caps the /// MoonShot corridor's `MShotAdd*` sum (`MshotParams::max_modifier`). pub max_modifier: f64, /// `StopLossModifier` — the same summed modifiers, applied to the STOP instead of the sell: /// the stop goes DEEPER by `StopLossModifier · Σ`, as the core's FAQ spells it: - /// `StopLoss adjusted [-1.00% - (10.00*0.98=9.75%) => -10.75%]`. Set on 415 of 1869 live - /// strategies, median 0.3. + /// `StopLoss adjusted [-1.00% - (10.00*0.98=9.75%) => -10.75%]`. Set on 150 of 1 423 live + /// strategies (2026-09-25), 0.2 on 139 of them. pub stop_loss_modifier: f64, /// The `Add*Delta` family of the Delta Modifiers tab — the same shape as MoonShot's /// `MShotAdd*` corridor modifiers, different fields: these move the ORDER PRICE, those the diff --git a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs index c7555c90..2285a71c 100644 --- a/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs +++ b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs @@ -39,8 +39,8 @@ impl ExitModel<'_> { /// The core sums the deltas as they stand when it places the sell: on 121 of its printed sums /// (2026-09-22) the report's snapshot, stamped at the entry order's placement for every kind but /// MoonShot, drifted from the core's number the more, the longer the entry order waited. So the -/// sum is read at `at_ms` through the deal's live coin deltas ([`Deal::deltas_at`]); the BTC, -/// market, mark and price-bug terms stay the snapshot. Where the record kept the core's own sum +/// sum is read at `at_ms` through the deal's live deltas ([`Deal::deltas_at`]) — the coin's and +/// BTC's; the market, mark and price-bug terms stay the snapshot. Where the record kept the core's own sum /// ([`Deal::fact_modifier`]), what the deltas miss of it is added back — scaled to these /// coefficients ([`FactModifier::residual_for`]), so the fact's own parameters read a sum that /// places the core's level to the price step, and a variant's the model's sum moved by the same diff --git a/crates/moon-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs index ac01e598..4df5eb35 100644 --- a/crates/moon-core/src/db/tuner/ticks/verify.rs +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -68,8 +68,8 @@ pub const POINT_TIME_TOLERANCE_MS: i64 = 1_000; pub const BOOK_STOP_TIME_TOLERANCE_MS: i64 = 2_300; /// Tolerance on a STOP's level: the modelled level against the one the core fixed carries -/// `StopLossModifier` over deltas the model only partly re-reads live — the coin's ranges where -/// the deal has a track, the BTC, market, mark and price-bug terms as the report's one snapshot +/// `StopLossModifier` over deltas the model only partly re-reads live — the coin's and BTC's where +/// the deal has a track, the market, mark and price-bug terms as the report's one snapshot /// (`exit::delta_mods::modifier_sum`) — and the residual sits right there wherever the record /// did not keep the core's own sum (`Deal::fact_modifier`). Where it did, the sum is the one /// that places the recorded level to the price step, and a stop read off the take's band carries From 9fdc3825a0c2c46be9de770ec6c9e1d48a53b165 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sat, 26 Sep 2026 17:25:34 +0200 Subject: [PATCH 49/51] feat(tuner): the share gate warns instead of locking a group out 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. --- crates/moon-core/src/config/layout.rs | 5 +- .../src/analytics/tuner/ticks/estimate.rs | 12 ++- .../src/analytics/tuner/ticks/grid.rs | 13 ++- .../src/analytics/tuner/ticks/rows/tests.rs | 44 +++++++++ .../src/analytics/tuner/ticks/state.rs | 40 +++++++- .../src/analytics/tuner/ticks/variants.rs | 96 +++++++++++++++---- locales/analytics.yml | 44 +++++---- 7 files changed, 212 insertions(+), 42 deletions(-) diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index 4e83fa64..92d54d29 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -639,8 +639,9 @@ pub struct TicksAxisLayout { pub seed: Option, /// Passes of coordinate descent per restart; `None` = the search's default. pub passes: Option, - /// The share of reproduced trades, per cent, a parameter group needs before it may be - /// searched; `None` = the axis default. + /// The share of reproduced trades, per cent, under which a parameter group's heading warns + /// that the search's answer speaks for fewer trades (it no longer locks the group out); + /// `None` = the axis default. pub gate_pct: Option, /// Strategy fields the search holds at their base value — the unticked grid rows. pub locked: Vec, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs index bab765f4..47a16f11 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs @@ -65,7 +65,7 @@ pub(super) struct Scope { impl AnalyticsView { /// The scope of a search of `only`, or of every ticked field: every ticked field of the - /// groups the gate lets through, or the one field with every other held. `Err` carries the + /// searchable groups, or the one field with every other held. `Err` carries the /// locale key of why it cannot start — `None` for a field the grid does not know. pub(super) fn ticks_search_scope( &self, @@ -83,7 +83,15 @@ impl AnalyticsView { return Err(Some("analytics.ticks.sugg_not_read")); } if !self.ticks_group_searchable(field.group) { - return Err(Some("analytics.ticks.sugg_gated")); + // Two causes, two answers: a kind whose entry is taken from the fact, or a + // group the model reproduced no trade of. + let no_entry_model = field.group == ParamGroup::Entry + && self.ticks.data.data().is_some_and(|d| !d.entry_modelled()); + return Err(Some(if no_entry_model { + "analytics.ticks.sugg_no_entry_model" + } else { + "analytics.ticks.sugg_unanswered" + })); } let locked = TICK_PARAMS .iter() diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs index 8a926b94..c45d69a8 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -278,8 +278,9 @@ impl AnalyticsView { head.into_any_element() } - /// Why a search group is not searched, when it is not — the kinds whose entry is taken from - /// the fact, or a share of reproduced trades under the gate — with the colour to say it in. + /// What a search group's heading warns of, when it does — the kinds whose entry is taken from + /// the fact (not searched), or a share of reproduced trades under the gate (searched, but its + /// answer speaks for fewer trades) — with the colour to say it in. fn ticks_group_note( &self, group: ParamGroup, @@ -302,6 +303,14 @@ impl AnalyticsView { ParamGroup::Entry => t!("analytics.ticks.group_entry"), ParamGroup::Exit => t!("analytics.ticks.group_exit"), }; + // Answered, but no trade fit for the search — whatever the share and the gate: the search + // has nothing to learn on and refuses the group (`TicksData::group_searchable`). + if n > 0 && !d.group_searchable(group) { + return Some(( + format!("{name}: {}", t!("analytics.ticks.vary_none")), + p.orange, + )); + } match d.group_passes(group, gate) { Some(false) => Some(( format!( diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs index 0a957cba..00c0f104 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -243,12 +243,56 @@ fn the_share_gate_answers_per_group_and_only_once_something_answered() { assert_eq!(data.group_passes(ParamGroup::Exit, 0.8), Some(false)); // The gate is the search settings' own: lowered, the exit group passes too. assert_eq!(data.group_passes(ParamGroup::Exit, 0.7), Some(true)); + // The warning, whatever the search makes of it: the exit under the gate, even with nothing + // reproduced — the worst case; the entry of a scope without an entry model never. + assert_eq!(data.under_gate(ParamGroup::Exit, 0.8), Some((7, 10))); + assert_eq!( + data.under_gate(ParamGroup::Exit, 0.7), + None, + "over the gate: no warning" + ); + assert_eq!( + data.under_gate(ParamGroup::Entry, 0.9), + None, + "no kind, no entry model: no warning" + ); + data.exit_share = (0, 10); + assert_eq!(data.under_gate(ParamGroup::Exit, 0.8), Some((0, 10))); data.kinds = vec!["MoonShot".into()]; assert_eq!(data.single_kind(), Some("MoonShot")); data.kinds.push("Spread".into()); assert_eq!(data.single_kind(), None); } +/// A group is searched when its kinds have a model of it and a trade is fit for the search — +/// under the share gate or not: the gate only warns. +#[test] +fn a_group_is_searched_with_a_model_and_a_fit_trade_whatever_its_share() { + use moon_core::db::tuner::ticks::params::ParamGroup; + let mut st = state(); + let data = st.data.data_mut().unwrap(); + // Row 2 is covered, reproduced on both sides and holds enough tape past its close. + assert_eq!(data.fit(), 1); + data.entry_share = (1, 10); + data.exit_share = (1, 10); + assert!( + data.group_searchable(ParamGroup::Exit), + "under the gate, searched" + ); + assert!( + !data.group_searchable(ParamGroup::Entry), + "no kind, no entry model" + ); + data.kinds = vec!["MoonShot".into()]; + assert!(data.group_searchable(ParamGroup::Entry)); + // No fit trade — the exit missed on the only covered one — and neither group has anything + // to learn on, the entry included: a fit trade needs its exit reproduced as well. + data.rows[1].verdict = Some(verdict(Some(true), Some(false))); + assert_eq!(data.fit(), 0); + assert!(!data.group_searchable(ParamGroup::Exit)); + assert!(!data.group_searchable(ParamGroup::Entry)); +} + #[test] fn invalidate_stops_the_search_and_drops_the_variant_scores_but_keeps_the_edits() { let mut state = state(); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs index b2ca900e..b34d1dc7 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -22,9 +22,11 @@ use moon_core::db::tuner::ticks::search::SearchResult; use moon_core::db::tuner::ticks::{Deal, Verdict, fit_for_search}; use moon_core::market::trade_replay::TickStatus; -/// Share of hits a group needs before it may be searched, per cent, when the search settings do -/// not say: a model that cannot reproduce the fact must not be asked what would have been -/// better. The spec's proposal, to be tuned by practice (`TicksState::gate_pct`). +/// Share of hits under which a group is flagged, per cent, when the search settings do not say. +/// A warning, not a lock (LinKvo, 2026-09-26): the search learns on the reproduced trades alone +/// (`fit_for_search`), and the accuracy line under the grid says how much of the history that +/// is — a group under the share may still be searched, its heading says the answer speaks for +/// fewer trades (`TicksState::gate_pct`). pub(in crate::analytics::tuner) const DEFAULT_GATE_PCT: u32 = 80; /// Bytes of tape kept in memory across every fit row, for the variants and the search — what @@ -248,7 +250,7 @@ impl TicksData { } /// The share gate per group: whether the model reproduces at least `gate` (a fraction) of - /// the fact to be searched over. `None` when nothing answered yet. + /// the fact — below it the group's heading warns. `None` when nothing answered yet. pub(in crate::analytics::tuner) fn group_passes( &self, group: ParamGroup, @@ -258,6 +260,36 @@ impl TicksData { (n > 0).then(|| hits as f64 / n as f64 >= gate) } + /// Whether the kinds of the scope have a model of the group: every kind has an exit, the + /// entry only where [`Self::entry_modelled`]. + fn group_modelled(&self, group: ParamGroup) -> bool { + match group { + ParamGroup::Entry => self.entry_modelled(), + ParamGroup::Exit => true, + } + } + + /// Whether a group may be searched: its kinds have a model of it, and at least one trade is + /// fit for the search ([`Self::fit`]) — the search learns on those alone (`fit_for_search`, + /// which needs the exit reproduced for an entry search too), so without one there is nothing + /// to learn on. The share gate does not lock it ([`DEFAULT_GATE_PCT`], [`Self::under_gate`]). + pub(in crate::analytics::tuner) fn group_searchable(&self, group: ParamGroup) -> bool { + self.group_modelled(group) && self.fit() > 0 + } + + /// `(hits, answered)` of a modelled group under the share gate — none reproduced included, + /// the worst case — which the grid heading, the search's tooltips and the write dialogs warn + /// of: the search learns on the reproduced trades alone. `None` for a group at or over the + /// gate, or with no model. + pub(in crate::analytics::tuner) fn under_gate( + &self, + group: ParamGroup, + gate: f64, + ) -> Option<(usize, usize)> { + (self.group_modelled(group) && self.group_passes(group, gate) == Some(false)) + .then(|| self.share_of(group)) + } + /// `(hits, answered)` of one group over the covered rows. pub(in crate::analytics::tuner) fn share_of(&self, group: ParamGroup) -> (usize, usize) { match group { diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index 8a009d00..bf1a3e66 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -197,20 +197,19 @@ impl AnalyticsView { .retain(|id, _| !id.starts_with(super::grid::VARIANT_INPUT_PREFIX)); } - /// Whether a group may be searched: the kind's support and the share gate. + /// Whether a group may be searched ([`super::state::TicksData::group_searchable`]): the + /// kind's support and a reproduced trade to learn from — the share gate only warns. pub(in crate::analytics::tuner) fn ticks_group_searchable(&self, group: ParamGroup) -> bool { - let Some(data) = self.ticks.data.data() else { - return false; - }; - let supported = match group { - ParamGroup::Entry => data.entry_modelled(), - ParamGroup::Exit => true, - }; - supported && data.group_passes(group, self.ticks.gate()) == Some(true) + self.ticks + .data + .data() + .is_some_and(|data| data.group_searchable(group)) } /// The tooltips of "Search" and "Search all": what each varies and where its answer lands — - /// the selected field by name, the number of ticked fields the gate lets through. A scope of + /// the selected field by name, the number of ticked fields of the searchable groups, and on + /// both the groups searched under the share gate ([`Self::ticks_gate_warnings`]) — the grid + /// heading that says so can be scrolled away by the time the button is pressed. A scope of /// several kinds, which neither searches, says so on both. pub(super) fn ticks_search_tips(&self) -> (String, String) { let data = self.ticks.data.data(); @@ -224,21 +223,84 @@ impl AnalyticsView { }; let entry_on = data.is_some_and(|d| d.entry_modelled()); let ticked = data.map_or(0, |d| { + // Once per group, not per knob: `group_searchable` counts the fit rows, and the + // tooltips are built on every paint of the search row. + let (entry_ok, exit_ok) = ( + d.group_searchable(ParamGroup::Entry), + d.group_searchable(ParamGroup::Exit), + ); d.grid .iter() .flat_map(super::sections::GridSection::knobs) .filter(|k| super::grid::knob_ticks(k, entry_on)) .filter(|k| !self.ticks.locked.contains(k.key)) - .filter(|k| self.ticks_group_searchable(k.group)) + .filter(|k| match k.group { + ParamGroup::Entry => entry_ok, + ParamGroup::Exit => exit_ok, + }) .count() }); - ( - one, - t!("analytics.ticks.suggest_all_tip", n = ticked).to_string(), - ) + let all = t!("analytics.ticks.suggest_all_tip", n = ticked).to_string(); + let gated = self.ticks_gate_warnings([ParamGroup::Entry, ParamGroup::Exit]); + if gated.is_empty() { + return (one, all); + } + let with = |tip: String| format!("{tip}\n\n{}", gated.join("\n")); + (with(one), with(all)) + } + + /// One line per group of `groups` under the share gate, none reproduced included: the search + /// learns on the fit trades alone (`TicksData::under_gate`). The search's tooltips and the + /// write dialogs carry it — the gate no longer locks such a group out, so a variant searched + /// on a small share of the fact can reach a live strategy, and the dialog is the last place + /// to say so. + fn ticks_gate_warnings(&self, groups: impl IntoIterator) -> Vec { + let Some(data) = self.ticks.data.data() else { + return Vec::new(); + }; + let gate = self.ticks.gate(); + let mut seen = Vec::new(); + groups + .into_iter() + .filter(|group| { + let first = !seen.contains(group); + seen.push(*group); + first + }) + .filter_map(|group| { + let (hits, n) = data.under_gate(group, gate)?; + let name = match group { + ParamGroup::Entry => t!("analytics.ticks.group_entry"), + ParamGroup::Exit => t!("analytics.ticks.group_exit"), + }; + Some( + t!( + "analytics.ticks.gate_warn", + group = name, + hits = hits, + n = n, + gate = (gate * 100.0).round() as i64 + ) + .to_string(), + ) + }) + .collect() + } + + /// The groups a variant's changes move — the gate's warning at write time is about them. + fn changed_groups(changes: &[(String, String)]) -> Vec { + changes + .iter() + .filter_map(|(key, _)| { + moon_core::db::tuner::ticks::TICK_PARAMS + .iter() + .find(|f| f.key == key.as_str()) + .map(|f| f.group) + }) + .collect() } - /// "Search all": every ticked field of the groups the gate lets through, each from the + /// "Search all": every ticked field of the searchable groups, each from the /// strategies, the unticked ones held at В1's value; the answer goes into В1 /// ([`land_answer`]). pub(in crate::analytics::tuner) fn ticks_suggest( @@ -528,6 +590,7 @@ impl AnalyticsView { let mut warns = self.ticks_change_warnings(&changes, cx); warns.extend(self.ticks_unguarded_warning(&targets, &changes, cx)); warns.extend(self.ticks_unmodelled_warns(&targets, cx)); + warns.extend(self.ticks_gate_warnings(Self::changed_groups(&changes))); self.open_change_dialog(targets, changes, None, Vec::new(), warns, false, cx); } @@ -544,6 +607,7 @@ impl AnalyticsView { let mut warns = self.ticks_change_warnings(&changes, cx); warns.extend(self.ticks_unguarded_warning(std::slice::from_ref(&target), &changes, cx)); warns.extend(self.ticks_unmodelled_warns(std::slice::from_ref(&target), cx)); + warns.extend(self.ticks_gate_warnings(Self::changed_groups(&changes))); self.open_copy_with(target, changes, warns, window, cx); } diff --git a/locales/analytics.yml b/locales/analytics.yml index 551fc322..410c909f 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -2017,9 +2017,13 @@ analytics.ticks.holdout: en: "holdout: %{n} trades, %{profit}" es: "holdout: %{n} operaciones, %{profit}" analytics.ticks.vary_gated: - ru: "Модель воспроизводит факт лишь в %{hits} из %{n} сделок — меньше %{gate} %, подбирать по ней нельзя" - en: "The model reproduces the fact on only %{hits} of %{n} trades — under %{gate} %, so it cannot be searched over" - es: "El modelo reproduce el hecho solo en %{hits} de %{n} operaciones — menos del %{gate} %, no se puede buscar sobre él" + ru: "Модель воспроизводит факт лишь в %{hits} из %{n} сделок — меньше %{gate} %: подбор идёт, но его ответ говорит только за воспроизведённые сделки" + en: "The model reproduces the fact on only %{hits} of %{n} trades — under %{gate} %: the search runs, but its answer speaks for the reproduced trades alone" + es: "El modelo reproduce el hecho solo en %{hits} de %{n} operaciones — menos del %{gate} %: la búsqueda se ejecuta, pero su respuesta vale solo para las operaciones reproducidas" +analytics.ticks.vary_none: + ru: "Нет ни одной годной сделки (модель воспроизвела и вход, и выход) — подбирать не по чему" + en: "No trade is fit for the search (entry and exit both reproduced) — there is nothing to search on" + es: "Ninguna operación es apta para la búsqueda (entrada y salida reproducidas) — no hay sobre qué buscar" analytics.ticks.vary_unknown: ru: "Доля воспроизведения ещё не посчитана — нет сделок с лентой" en: "The reproduction share is not known yet — no trades with tape" @@ -2185,9 +2189,9 @@ analytics.ticks.suggest_one_tip: en: "Search the selected field %{field} alone, ticked or not. The field is searched anew from the strategy's values, whatever V1 holds; the other fields stay as V1 has them. The answer goes into V1's %{field} cell (emptied when it is the strategy's own value) — and into the fields a switch it turns on needs (UseTakeProfit → TakeProfit)" es: "Buscar solo el campo seleccionado %{field}, marcado o no. El campo se busca de nuevo desde los valores de la estrategia, tenga lo que tenga V1; los demás campos quedan como en V1. La respuesta va a la celda %{field} de V1 (se vacía si coincide con la de la estrategia) — y a los campos que requiere un interruptor que active (UseTakeProfit → TakeProfit)" analytics.ticks.suggest_all_tip: - ru: "Подобрать все поля с галочкой в группах, прошедших порог воспроизводимости, — сейчас их %{n}. Поле с галочкой подбирается заново от значений стратегии, что бы ни стояло в В1; его ячейка В1 получает ответ, а совпал он со стратегией — очищается. Поле без галочки держится как в В1 и его ячейка не трогается, кроме значения, которое требует включённый подбором переключатель (UseTakeProfit → TakeProfit): оно одно на все стратегии" - en: "Search every ticked field of the groups past the reproduction gate — %{n} now. A ticked field is searched anew from the strategy's values, whatever V1 holds; its V1 cell takes the answer, and is emptied when the answer is the strategy's own value. An unticked field stays as V1 has it and its cell is left alone, but for a value a switch the search turns on needs (UseTakeProfit → TakeProfit): one value for every strategy" - es: "Buscar todos los campos marcados de los grupos que pasan el umbral de reproducción — ahora %{n}. Un campo marcado se busca de nuevo desde los valores de la estrategia, tenga lo que tenga V1; su celda de V1 recibe la respuesta y se vacía si coincide con la de la estrategia. Un campo sin marcar queda como en V1 y su celda no se toca, salvo el valor que requiere un interruptor que la búsqueda active (UseTakeProfit → TakeProfit): un valor para todas las estrategias" + ru: "Подобрать все поля с галочкой в группах, которые можно подбирать (есть модель и хоть одна воспроизведённая сделка), — сейчас их %{n}. Поле с галочкой подбирается заново от значений стратегии, что бы ни стояло в В1; его ячейка В1 получает ответ, а совпал он со стратегией — очищается. Поле без галочки держится как в В1 и его ячейка не трогается, кроме значения, которое требует включённый подбором переключатель (UseTakeProfit → TakeProfit): оно одно на все стратегии" + en: "Search every ticked field of the searchable groups (a model, and at least one reproduced trade) — %{n} now. A ticked field is searched anew from the strategy's values, whatever V1 holds; its V1 cell takes the answer, and is emptied when the answer is the strategy's own value. An unticked field stays as V1 has it and its cell is left alone, but for a value a switch the search turns on needs (UseTakeProfit → TakeProfit): one value for every strategy" + es: "Buscar todos los campos marcados de los grupos que se pueden buscar (con modelo y al menos una operación reproducida) — ahora %{n}. Un campo marcado se busca de nuevo desde los valores de la estrategia, tenga lo que tenga V1; su celda de V1 recibe la respuesta y se vacía si coincide con la de la estrategia. Un campo sin marcar queda como en V1 y su celda no se toca, salvo el valor que requiere un interruptor que la búsqueda active (UseTakeProfit → TakeProfit): un valor para todas las estrategias" analytics.ticks.suggest_one_none: ru: "Выберите поле: щёлкните по его имени в таблице" en: "Pick a field: click its name in the grid" @@ -2196,10 +2200,18 @@ analytics.ticks.sugg_not_read: ru: "Выбранный способ входа это поле не читает — подбирать нечего" en: "The entry method in force does not read this field — nothing to search" es: "El método de entrada activo no lee este campo — nada que buscar" -analytics.ticks.sugg_gated: - ru: "Группа этого поля не проходит порог воспроизводимости — подбор по ней закрыт" - en: "This field's group is under the reproduction gate — it cannot be searched" - es: "El grupo de este campo está bajo el umbral de reproducción — no se puede buscar" +analytics.ticks.sugg_unanswered: + ru: "Нет ни одной годной сделки (модель воспроизвела и вход, и выход) — подбирать не по чему" + en: "No trade is fit for the search (entry and exit both reproduced) — there is nothing to search on" + es: "Ninguna operación es apta para la búsqueda (entrada y salida reproducidas) — no hay sobre qué buscar" +analytics.ticks.sugg_no_entry_model: + ru: "Вход этого вида стратегий не моделируется — берётся из факта, подбирать нечего" + en: "This kind's entry is not modelled — it is taken from the fact, there is nothing to search" + es: "La entrada de este tipo no se modela — se toma del hecho, no hay nada que buscar" +analytics.ticks.gate_warn: + ru: "%{group}: модель воспроизводит лишь %{hits} из %{n} сделок (меньше %{gate} %) — подбор учится только на годных сделках, за остальную историю его ответ не говорит" + en: "%{group}: the model reproduces only %{hits} of %{n} trades (under %{gate} %) — the search learns on the fit trades alone and does not speak for the rest of the history" + es: "%{group}: el modelo reproduce solo %{hits} de %{n} operaciones (menos del %{gate} %) — la búsqueda aprende solo de las operaciones aptas y no responde por el resto del historial" analytics.ticks.cfg_passes: ru: "Проходов на перезапуск" en: "Passes per restart" @@ -2209,13 +2221,13 @@ analytics.ticks.cfg_passes_tip: en: "How many times the descent visits every field before a restart counts as converged" es: "Cuántas veces el descenso recorre todos los campos antes de dar un reinicio por convergido" analytics.ticks.cfg_gate: - ru: "Порог группы, %" - en: "Group gate, %" - es: "Umbral del grupo, %" + ru: "Порог предупреждения, %" + en: "Warning share, %" + es: "Umbral de aviso, %" analytics.ticks.cfg_gate_tip: - ru: "Какая доля сделок должна воспроизводиться моделью, чтобы группу (вход или выход) можно было подбирать" - en: "The share of trades the model must reproduce before a group (entry or exit) may be searched" - es: "La cuota de operaciones que el modelo debe reproducir para que un grupo (entrada o salida) se pueda buscar" + ru: "Ниже этой доли воспроизведённых сделок заголовок группы (вход или выход) предупреждает, что ответ подбора говорит только за них; подбор при этом разрешён" + en: "Under this share of reproduced trades the group's heading (entry or exit) warns that the search's answer speaks for them alone; the search still runs" + es: "Por debajo de esta cuota de operaciones reproducidas el encabezado del grupo (entrada o salida) avisa de que la respuesta de la búsqueda vale solo para ellas; la búsqueda se ejecuta igualmente" analytics.ticks.cfg_steps: ru: "Шагов на параметр" en: "Steps per field" From a4efe3599e21bb739dc1b8e5e68cabbf28370fb4 Mon Sep 17 00:00:00 2001 From: guyverino Date: Sat, 26 Sep 2026 17:54:09 +0200 Subject: [PATCH 50/51] chore(tuner): clear the clippy findings the branch added 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. --- .../src/db/tuner/ticks/params/range.rs | 5 ++- crates/moon-core/src/db/tuner/ticks/search.rs | 2 +- crates/moon-core/src/db/tuner/ticks/tests.rs | 36 ++++++++++++------- .../src/analytics/tuner/filter/mod.rs | 2 +- .../src/analytics/tuner/ticks/estimate.rs | 2 +- .../src/analytics/tuner/ticks/fetch.rs | 4 +-- .../src/analytics/tuner/ticks/load.rs | 14 ++++---- .../src/analytics/tuner/ticks/trade_pane.rs | 2 +- .../src/analytics/tuner/ticks/variants.rs | 2 +- 9 files changed, 41 insertions(+), 28 deletions(-) diff --git a/crates/moon-core/src/db/tuner/ticks/params/range.rs b/crates/moon-core/src/db/tuner/ticks/params/range.rs index c8ad182d..49f9c64a 100644 --- a/crates/moon-core/src/db/tuner/ticks/params/range.rs +++ b/crates/moon-core/src/db/tuner/ticks/params/range.rs @@ -255,9 +255,9 @@ impl FieldSpan { fn round_step(raw: f64, decimals: u32) -> f64 { let quantum = quantum(decimals); let raw = raw.max(quantum); - let mut power = raw.log10().floor() as i32 - 1; + let first = raw.log10().floor() as i32 - 1; // Past ten decades up there is always 10^power itself, a multiple of any quantum ≤ 1. - for _ in 0..12 { + for power in first..first + 12 { for mantissa in [1.0, 2.0, 2.5, 5.0] { let candidate = snap(mantissa * 10f64.powi(power), MAX_DECIMALS + 4); let multiple = candidate / quantum; @@ -265,7 +265,6 @@ fn round_step(raw: f64, decimals: u32) -> f64 { return candidate; } } - power += 1; } raw } diff --git a/crates/moon-core/src/db/tuner/ticks/search.rs b/crates/moon-core/src/db/tuner/ticks/search.rs index cb5e1ee6..afc85648 100644 --- a/crates/moon-core/src/db/tuner/ticks/search.rs +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -420,7 +420,7 @@ fn perturb( let index = match (&field.kind, start.get(field.key)) { (ParamKind::Num, Some(&at)) => { let step = 1 + (next_random(state) % 3) as usize; - if next_random(state) % 2 == 0 { + if next_random(state).is_multiple_of(2) { at.saturating_sub(step) } else { (at + step).min(n - 1) diff --git a/crates/moon-core/src/db/tuner/ticks/tests.rs b/crates/moon-core/src/db/tuner/ticks/tests.rs index 09825834..1c3f1e4b 100644 --- a/crates/moon-core/src/db/tuner/ticks/tests.rs +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -1680,8 +1680,10 @@ fn a_moonshot_lifted_to_the_ask_needs_the_recorded_ask() { /// (the core developer via LinKvo, 2026-09-24). #[test] fn a_negative_coefficient_takes_the_sell_under_its_floor() { - let mut mods = Modifiers::default(); - mods.add_1h = 1.0; + let mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; let params = ExitParams { sell_price_pct: 1.0, sell_modifier: -0.5, @@ -1806,8 +1808,10 @@ fn an_unknown_take_leaves_the_exit_unanswered() { /// => -10.75%]`: the configured stop, deepened by `StopLossModifier · Σ`. #[test] fn the_stop_modifier_deepens_the_stop_by_the_summed_deltas() { - let mut mods = Modifiers::default(); - mods.add_1h = 1.0; + let mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; let params = ExitParams { stop_loss_pct: -2.0, stop_loss_modifier: 0.2, @@ -1847,8 +1851,10 @@ fn the_stop_modifier_deepens_the_stop_by_the_summed_deltas() { /// than one a hair from the entry that the next print would trip. #[test] fn an_adjustment_through_the_entry_leaves_no_stop() { - let mut mods = Modifiers::default(); - mods.add_1h = 1.0; + let mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; let base = ExitParams { stop_loss_pct: -2.0, stop_loss_modifier: -0.3, @@ -1912,8 +1918,10 @@ fn an_adjustment_through_the_entry_leaves_no_stop() { /// where the next print fires it. #[test] fn a_cancelled_stop_does_not_fire_at_the_entry() { - let mut mods = Modifiers::default(); - mods.add_1h = 1.0; + let mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; let params = ExitParams { stop_loss_pct: -2.0, stop_loss_modifier: -0.2, @@ -1954,8 +1962,10 @@ fn a_cancelled_stop_does_not_fire_at_the_entry() { /// divides the fill by it). #[test] fn a_short_stop_sits_above_with_the_modifier() { - let mut mods = Modifiers::default(); - mods.add_1h = 1.0; + let mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; let params = ExitParams { stop_loss_pct: -2.0, stop_loss_modifier: 0.2, @@ -1987,8 +1997,10 @@ fn a_short_stop_sits_above_with_the_modifier() { /// FAQ: a summed delta of 5 % with `SellModifier = 0.2` places the sell 1 % higher. #[test] fn sell_modifiers_lift_the_take_by_the_faq_example() { - let mut mods = Modifiers::default(); - mods.add_1h = 1.0; + let mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; let params = ExitParams { sell_price_pct: 1.0, sell_modifier: 0.2, diff --git a/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs index 8aaab5f2..ceb9a74a 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs @@ -96,7 +96,7 @@ impl AnalyticsView { &self, cx: &Context, ) -> HashMap { - super::ticks::strategy_field_defaults(&self.backend.read(cx)) + super::ticks::strategy_field_defaults(self.backend.read(cx)) } /// Recompute only KPI and selected strategy thresholds after a local tuner edit. diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs index 47a16f11..322f4dae 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs @@ -238,7 +238,7 @@ impl AnalyticsView { self.ticks.cost_task = Some(cx.spawn(async move |this, cx| { let executor = cx.update(|cx| cx.background_executor().clone()); executor.timer(COST_DEBOUNCE).await; - let _ = cx.update(|cx| { + cx.update(|cx| { let _ = this.update(cx, |this, cx| this.run_ticks_cost(key, cx)); }); })); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs index f499a762..bfe70c97 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -176,7 +176,7 @@ impl AnalyticsView { return; }; let backend = self.backend.read(cx); - let resolver = FetchResolver::of(&backend); + let resolver = FetchResolver::of(backend); let mut rows: Vec = data .fetchable() .filter_map(|row| resolver.queued_row(row.deal.clone(), row.address.clone()?)) @@ -185,7 +185,7 @@ impl AnalyticsView { // job pops from the end. rows.reverse(); let wanted: HashSet = rows.iter().map(|r| r.deal.report_uid).collect(); - let defaults = strategy_field_defaults(&backend); + let defaults = strategy_field_defaults(backend); if job::enqueue(rows, defaults) > 0 { self.attach_fetch_listener(cx); } diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs index d6d2072d..c55d87a1 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -266,7 +266,7 @@ impl AnalyticsView { deals: &[Deal], cx: &Context, ) -> HashMap<(u64, String), Option>> { - let mut resolver = super::fetch::FetchResolver::of(&self.backend.read(cx)); + let mut resolver = super::fetch::FetchResolver::of(self.backend.read(cx)); deals .iter() .map(|deal| ((deal.core_uid, deal.coin.clone()), resolver.address(deal))) @@ -282,6 +282,7 @@ impl AnalyticsView { /// whole table again: the table and the KPI blinked empty for the length of that, and the /// work grew with the table, not with what changed. A row the model already judged under /// the settings in force keeps its verdict; stage C reads and replays only the others. + #[allow(clippy::too_many_arguments)] fn start_replay_stage( &mut self, req: u64, @@ -505,11 +506,12 @@ impl AnalyticsView { // continuation read `Missing`, and its refusal is the walk's own word. // Nor is a row whose held query went unanswered: the tile store was not // read for it, so "cannot be fetched" would be said of a store never asked. - if answered && row.tape == TapeStatus::Missing { - if let Some(address) = row.address.as_ref() { - row.tape = unservable_status(address, &row.deal, now_ms) - .unwrap_or(TapeStatus::Missing); - } + if answered + && row.tape == TapeStatus::Missing + && let Some(address) = row.address.as_ref() + { + row.tape = unservable_status(address, &row.deal, now_ms) + .unwrap_or(TapeStatus::Missing); } } log_replay(&rows); diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs index c13ee9f3..a3eebdf4 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs @@ -214,7 +214,7 @@ impl AnalyticsView { }) }) .collect(); - let _ = cx.update(|cx| { + cx.update(|cx| { let _ = this.update(cx, |this, cx| { if this.ticks.trade.model_seq != seq { return; diff --git a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs index bf1a3e66..8556585c 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -103,7 +103,7 @@ impl AnalyticsView { self.ticks.var_task = Some(cx.spawn(async move |this, cx| { let executor = cx.update(|cx| cx.background_executor().clone()); executor.timer(VARIANT_DEBOUNCE).await; - let _ = cx.update(|cx| { + cx.update(|cx| { let _ = this.update(cx, |this, cx| { if this.ticks.var_seq == req { this.run_ticks_variants(req, cx); From 2b611924f8c4678181e2e2c23f92459426ee18dc Mon Sep 17 00:00:00 2001 From: guyverino Date: Sat, 26 Sep 2026 17:54:10 +0200 Subject: [PATCH 51/51] docs(product-map): list the Entry/Exit tuner axis --- docs/PRODUCT_MAP.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PRODUCT_MAP.md b/docs/PRODUCT_MAP.md index 30e1a905..0e8de1fe 100644 --- a/docs/PRODUCT_MAP.md +++ b/docs/PRODUCT_MAP.md @@ -106,7 +106,10 @@ deleted, comments, the core log for a trade. **Analytics**: KPI summary, profit calendar (year in GitHub style / month), live Profit Monitor (by cores/groups, start/stop cores right from the table), Tuner: “what-if” on report fields, Beam search over combinations, -By coin and By time axes (heatmap sliders for week/day/hour), a check +By coin and By time axes (heatmap sliders for week/day/hour), an Entry/Exit +axis that replays every closed trade on its recorded tape of prints and searches +the strategy's entry and exit fields (MoonShot corridor, MoonHook take, sell line, +stops, delta modifiers) on the trades the model reproduces, a check on held-out data, writing thresholds back into the core's strategy; history of strategy versions with each version's profit; a strategy-name mask everywhere.