diff --git a/assets/param_deps.toml b/assets/param_deps.toml index f99832b55..ba5f9c1fa 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-chart/src/frozen_overlay.rs b/crates/moon-chart/src/frozen_overlay.rs index a27822a3b..487b6860c 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 05319cca6..5005b6d14 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 e24abcc13..92d54d29c 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}; @@ -361,6 +362,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 +376,7 @@ impl Default for StratColsByMode { filter: 0, coins: 0, time: 0, + ticks: None, } } } @@ -619,6 +625,49 @@ 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, 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, + /// 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, + /// 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, + /// 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, + /// 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. /// /// Every field is `Option` or carries `#[serde(default)]` on purpose, and prefers a type wider @@ -809,6 +858,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")] @@ -1110,6 +1168,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/serde_compat.rs b/crates/moon-core/src/config/layout/serde_compat.rs index c1206c219..636e5f225 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 4de5a9479..c392e0ba1 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!( @@ -2197,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"); @@ -2214,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 @@ -2302,3 +2335,128 @@ 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()], + 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, + ..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); + + // 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; + assert_eq!(model.latency_ms, 150.0); + assert_eq!( + model.ticker_period_ms, + crate::db::tuner::ticks::exit::stops::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); +} + +/// 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/config/mod.rs b/crates/moon-core/src/config/mod.rs index 691af01ee..b129d0f56 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/config/storage.rs b/crates/moon-core/src/config/storage.rs index 336bbebb0..f760223df 100644 --- a/crates/moon-core/src/config/storage.rs +++ b/crates/moon-core/src/config/storage.rs @@ -67,14 +67,20 @@ pub struct TradeReplayStoreCfg { /// 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 + /// request budget without being asked. A file written before the field reads as off. + pub autoload_missing: bool, /// 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. + /// 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. - /// Off by default: it rewrites the file unasked. A file written before the field reads as - /// off. + /// 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, } @@ -117,6 +123,7 @@ impl Default for TradeReplayStoreCfg { persist_trades: true, 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, } @@ -133,6 +140,7 @@ struct TradeReplayStoreRaw { max_mb: u32, margin_s: Option, margin_min: Option, + autoload_missing: bool, long_position_min: u32, cleanup_at_startup: bool, } @@ -145,6 +153,7 @@ impl Default for TradeReplayStoreRaw { max_mb: d.max_mb, 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, } @@ -161,6 +170,7 @@ impl From for TradeReplayStoreCfg { persist_trades: raw.persist_trades, 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, } diff --git a/crates/moon-core/src/config/storage/tests.rs b/crates/moon-core/src/config/storage/tests.rs index c268518de..2448ecfb7 100644 --- a/crates/moon-core/src/config/storage/tests.rs +++ b/crates/moon-core/src/config/storage/tests.rs @@ -16,8 +16,11 @@ 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. + // 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 @@ -64,11 +67,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"); diff --git a/crates/moon-core/src/db/analytics/mod.rs b/crates/moon-core/src/db/analytics/mod.rs index 7461f1c5d..c218c4616 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/analytics/query/mod.rs b/crates/moon-core/src/db/analytics/query/mod.rs index 124d9f9bf..ce0905d9e 100644 --- a/crates/moon-core/src/db/analytics/query/mod.rs +++ b/crates/moon-core/src/db/analytics/query/mod.rs @@ -502,12 +502,27 @@ 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, // `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", + // 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/mod.rs b/crates/moon-core/src/db/mod.rs index d90f29fa2..c57c940e1 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 144fbc523..56e3db396 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 139307921..7f1baaa09 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, + 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, @@ -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. @@ -637,7 +668,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/strategy_read.rs b/crates/moon-core/src/db/tuner/strategy_read.rs index 99c0de8e2..b14a32895 100644 --- a/crates/moon-core/src/db/tuner/strategy_read.rs +++ b/crates/moon-core/src/db/tuner/strategy_read.rs @@ -143,35 +143,90 @@ 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 { - return None; + 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(), }; - for key in keys { - let Some(v) = map.get(key) else { continue }; - let s = 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(","), - _ => continue, - }; - out.insert(key.clone(), s); + 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(), } - Some(out) } /// The strategy KIND (`SignalType`: `MoonShot`, `Spread`, …) of each `(strategy_id, core_uid)` @@ -226,6 +281,155 @@ pub fn strategy_kinds(pairs: &[(i64, u64)]) -> std::collections::HashMap<(i64, u 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> { + 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 { + let Some(text) = map.get(key).and_then(value_text) else { + continue; + }; + 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 @@ -328,3 +532,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 000000000..54dc74d96 --- /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/threshold_search/handle.rs b/crates/moon-core/src/db/tuner/threshold_search/handle.rs index ecfc272f3..ab06a959c 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. @@ -91,10 +93,22 @@ 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); } + /// 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. /// @@ -108,7 +122,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 1ee79c102..c8f89ca7c 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 59aabad40..020dee8d3 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/calibrate.rs b/crates/moon-core/src/db/tuner/ticks/calibrate.rs new file mode 100644 index 000000000..e8a0e8fd3 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/calibrate.rs @@ -0,0 +1,98 @@ +//! 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::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. +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, 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 + .windows(2) + .skip(1) + .map(|pair| pair[1].0 - pair[0].0 - delay_ms) + .filter(|lag| (0..delay_ms / 2).contains(lag)) + .collect() +} + +/// 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. +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 000000000..7ccfeacce --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/calibrate/tests.rs @@ -0,0 +1,131 @@ +//! 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, + fact_modifier: None, + hook_depth_pct: None, + hook_stated_take_pct: None, + step_lag_ms: 0.0, + stop_anchor: None, + delta_track: None, + bars: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + gap: None, + } +} + +/// 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()); +} + +/// 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() { + 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 new file mode 100644 index 000000000..076ffda66 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deals.rs @@ -0,0 +1,391 @@ +//! 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. 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 std::collections::HashMap; + +use rusqlite::Connection; + +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; +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, + /// 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`]. + pub untunable: 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; 16] = [ + "d5s", + "d1m", + "d5m", + "d15m", + "d1h", + "d3h", + "d24h", + "dmark", + "pricebug", + "btc1hdelta", + "btc5mdelta", + "exchange1hdelta", + "dbtc1m", + "exchange24hdelta", + "pump1h", + "dump1h", +]; + +/// 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 counts left out; `NotReady` +/// when no report source has the schema yet. +pub fn read_deals(q: &Query) -> ReadResult { + // 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)?; + 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!( + 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 + .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(); + } + } + let before = read.deals.len(); + read.deals + .retain(|deal| is_tunable(&deal.kind, &deal.sell_reason)); + read.untunable = before - read.deals.len(); + 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}, + 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))?; + 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 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; + } + // 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; 16] = [ + &mut deltas.d5s, + &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, + &mut deltas.btc1m, + &mut deltas.market24h, + &mut deltas.pump1h, + &mut deltas.dump1h, + ]; + for (offset, slot) in slots.into_iter().enumerate() { + *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, + core_name: r + .get::<_, Option>(name_at) + .map_err(fail)? + .unwrap_or_default(), + strategy_id, + kind: String::new(), + coin: r + .get::<_, Option>(3) + .map_err(fail)? + .unwrap_or_default(), + buy_ms, + buy_set_ms, + corridor, + close_ms, + buy_price: num(6)?, + sell_price: num(7)?, + spent: num(8)?, + 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, + // 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, + fact_modifier: 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, + 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)); + } + // 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) +} + +/// 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 +/// 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)"; + // 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<(u64, i64), f64> = HashMap::new(); + 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), + ); + let profit = r + .get::<_, Option>(2) + .map_err(|e| read_fail_on(conn, CTX, e))? + .filter(|v| v.is_finite()) + .unwrap_or(0.0); + money.insert(key, profit); + } + let mut unpriced = 0usize; + for deal in deals.iter_mut() { + deal.profit = money.get(&(deal.core_uid, 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 new file mode 100644 index 000000000..aafa9956a --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deals/tests.rs @@ -0,0 +1,163 @@ +//! 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), + (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 +} + +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"); + // 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); + 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); + // 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!( + read.deals.iter().all(|d| d.kind.is_empty()), + "kinds are resolved by the caller" + ); +} + +/// 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"); + 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/deltas/btc.rs b/crates/moon-core/src/db/tuner/ticks/deltas/btc.rs new file mode 100644 index 000000000..eae063a2d --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/btc.rs @@ -0,0 +1,152 @@ +//! 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, +//! `(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::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; + +/// 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, + /// 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)>, +} + +impl<'a> BtcEval<'a> { + pub(super) fn new(series: &'a Series) -> Self { + Self { + series, + windows: [Extremes::new(), Extremes::new()], + next: 0, + 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 { + 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.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.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, + }; + 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 000000000..f558c1a26 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/coin.rs @@ -0,0 +1,192 @@ +//! 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 / 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). +//! +//! 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. +/// +/// 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, + 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; +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 that read up to the moment. + next: usize, + /// 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)>, +} + +impl<'a> CoinEval<'a> { + pub(super) fn new(series: &'a Series) -> Self { + Self { + series, + windows: std::array::from_fn(|_| Extremes::new()), + next: 0, + next_closed: 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; + let closed = last_close(at); + while self.next < items.len() && items[self.next].end_ms <= at { + for (w, window) in self.windows.iter_mut().enumerate() { + if !CLOSED_ONLY[w] { + window.push(self.next, items); + } + } + self.next += 1; + } + 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), + }; + // 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 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, at - hour_start, 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 = |w: usize| { + let (from, to) = span(w); + series.covered_fraction(from, to) + }; + [ + (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/field.rs b/crates/moon-core/src/db/tuner/ticks/deltas/field.rs new file mode 100644 index 000000000..a9382c16f --- /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 000000000..5ad3b9db6 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/mod.rs @@ -0,0 +1,513 @@ +//! 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 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 +//! 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 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 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; + +/// 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 { + 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 — 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], +} + +/// 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); + 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 (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(fives.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 +} + +/// 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. +/// 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, +) -> History { + let Some((from, to)) = covered.hull() else { + return History::default(); + }; + let eval = eval_span(deal); + 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 + .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(), + }; + let track = DeltaTrack::build(TrackInputs { + coin_bars: &coin_bars, + ticks, + covered: covered.spans(), + btc_bars: &btc_bars, + eval, + anchor: Some((at, &deal.deltas)), + }) + .map(Arc::new); + History { track, bars } +} + +#[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 000000000..5105c4714 --- /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.abs()); + } + } + } + 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 000000000..fa8d4f7a4 --- /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 new file mode 100644 index 000000000..7e8ca5e95 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/deltas/tests.rs @@ -0,0 +1,432 @@ +use super::*; +use crate::feed::types::Side; + +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 { + time_ms: t_ms as f64, + price, + qty: 1.0, + side: Side::Buy, + } +} + +/// 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() { + let ticks = [tick(T0 + 1_000, 100.0), tick(T0 + 7_000, 110.0)]; + 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. + 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 = day_of_bars(); + for b in &mut bars { + if b.to_ms == T0 - 19 * MINUTE_MS { + b.high = 120.0; + } + } + let ticks = [tick(T0 + 1_000, 100.0)]; + 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. + for field in [DeltaField::D1h, DeltaField::D3h, DeltaField::D24h] { + assert!( + close(track.value(T0 + MINUTE_MS, field).unwrap(), 20.0), + "{field:?}" + ); + } +} + +/// "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 + // 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 snapshot = Deltas { + d1m: 1.0, + d5m: 1.0, + d15m: 1.0, + d1h: 1.0, + d3h: 1.0, + d24h: 12.0, + ..Deltas::default() + }; + 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, 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, range - 12.0), "{error}"); + assert!(close(track.apply(T0 + 5_000, &snapshot).d24h, 12.0)); +} + +#[test] +fn nothing_evaluated_is_no_track() { + assert!( + 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 ticks = [tick(T0 + 1_000, 100.0), tick(T0 + 12_000, 104.0)]; + // 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(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); + 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!( + close(later.d1m, 5.0) && close(later.d24h, 13.0), + "{later:?}" + ); + assert!( + close(later.d15m, 0.0), + "a field the report never filled: {later:?}" + ); + assert!(!track.is_live(DeltaField::D15m)); + // Signed: the evaluation saw 0 where the report said 1. + assert_eq!(track.stamp().error[DeltaField::D1m.index()], Some(-1.0)); +} + +#[test] +fn an_anchor_the_track_does_not_reach_is_no_track() { + 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. + assert!( + 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. + 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_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 = 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 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 ticks = [tick(T0 + 1_000, 100.0)]; + let track = raw( + &day_of_bars(), + &ticks, + (T0, T0 + 60 * MINUTE_MS), + (T0 + MINUTE_MS, T0 + 2 * MINUTE_MS), + ); + 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)); + // 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. + 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.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] +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)); +} + +#[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 ticks = [tick(T0 + 1_000, 100.0), tick(T0 + 2_000, 102.0)]; + 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/entry.rs b/crates/moon-core/src/db/tuner/ticks/entry.rs new file mode 100644 index 000000000..12698b476 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/entry.rs @@ -0,0 +1,50 @@ +//! 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::{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). +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. + /// 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. +/// +/// 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<'_> { + /// 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 { + 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/exit.rs b/crates/moon-core/src/db/tuner/ticks/exit.rs new file mode 100644 index 000000000..9b55751d6 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit.rs @@ -0,0 +1,342 @@ +//! 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. +//! +//! One file per section of the strategy window, in the window's order: [`stops`], +//! [`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. +//! +//! [`ExitKind::OpenAtWindowEnd`]: super::ExitKind::OpenAtWindowEnd + +pub mod delta_mods; +pub mod line; +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; +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)] +struct Side { + long: bool, +} + +impl Side { + /// `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) + } 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 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)), + ) + } +} + +/// 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 its section's module. +#[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, + /// `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|)` — the core caps the sum's magnitude, so the sum is never + /// 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 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 + /// entry corridor, and one strategy can carry both. + pub sell_mods: Modifiers, + // 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, + // 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 + /// 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 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 [`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 `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, + /// 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 + /// would have made of it. + pub unmodelled: Option, + /// 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 + /// 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, +} + +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, + 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, + 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, + 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 + // 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, + second_stop: None, + third_stop: None, + unmodelled: None, + model: ModelSettings::default(), + 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 { + /// `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. +pub struct ExitModel<'a> { + params: &'a ExitParams, +} + +impl<'a> ExitModel<'a> { + pub fn new(params: &'a ExitParams) -> Self { + Self { params } + } + + /// 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); + 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)) + } +} + +#[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 000000000..2285a71c1 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods.rs @@ -0,0 +1,304 @@ +//! 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::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 + /// `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|)` — 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). +/// +/// 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 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 +/// miss. +/// +/// Args: +/// params: The sell parameters, for the coefficients and the ceiling. +/// 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 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 { + sum + } +} + +/// 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 new file mode 100644 index 000000000..ffb73786c --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/delta_mods/tests.rs @@ -0,0 +1,271 @@ +//! 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 +/// 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); +} + +/// 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/line.rs b/crates/moon-core/src/db/tuner/ticks/exit/line.rs new file mode 100644 index 000000000..047f653a4 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/line.rs @@ -0,0 +1,459 @@ +//! 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::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::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; + +/// 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, + /// 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 +/// 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 + }, + }) + } + + /// 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, + points: self.points, + stop_level: None, + } + } +} + +/// 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, 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; + 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 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. + 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) { + 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 + // 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 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 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. + finish( + line, + Exit { + t_ms: tail, + price: f64::NAN, + kind: ExitKind::OpenAtWindowEnd, + }, + &stops, + ) +} + +/// 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); + walked.stop_level = stops.level(); + walked +} + +#[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 000000000..f64801ada --- /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 000000000..b6d6bd77d --- /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 000000000..a98e18fd1 --- /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 000000000..08b376fad --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order.rs @@ -0,0 +1,446 @@ +//! 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 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 — [`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 +//! (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. 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, 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; + +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 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 — + // 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; + } + } + // 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). + 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 + .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) + }; + } + } + // 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 + } + + /// 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. + 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); + } + } + 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; 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 + /// [`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) +} + +/// 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; + 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)) +} + +/// 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.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, + } + } + + /// 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 { + // 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); + 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, + /// 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, 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 + && 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.off_buy(fill.price, params.sell_level_allowed_drop_pct), + bars: deal.bars.as_deref().unwrap_or_default(), + } + } + + /// 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 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); + 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); + } +} + +#[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 000000000..c4bcdafb1 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/sell_order/tests.rs @@ -0,0 +1,366 @@ +//! 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'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_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 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 - 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 ------------------------------------------------------------------------------- + +#[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)); +} + +/// 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 { + 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); +} + +// ---- 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 new file mode 100644 index 000000000..e647c7f37 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops.rs @@ -0,0 +1,556 @@ +//! 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 [`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}; +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 · Σ`. +/// +/// 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 +/// 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 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, + /// 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. + 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. `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 = 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 + // 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) + }; + // 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), + } + } + + /// 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 + /// 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`. + /// 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 + /// 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 { + self.climb(t_ms); + 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); + } + if let Some(ladder) = self.ladder.as_mut() { + ladder.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 { + // 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, + }; + 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/ladder.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs new file mode 100644 index 000000000..b5f9ff567 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/ladder.rs @@ -0,0 +1,161 @@ +//! 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 + } + + /// 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 { + 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 000000000..99ee7ca82 --- /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/exit/stops/tests.rs b/crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs new file mode 100644 index 000000000..709964d11 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/stops/tests.rs @@ -0,0 +1,279 @@ +//! 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::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}; +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!((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!((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!(level_off_buy(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 000000000..9b6220368 --- /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::super::level_off_buy; +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| level_off_buy(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| level_off_buy(buy, tp, long)); + let activation = params + .trailing_take_profit_pct + .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; + 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 000000000..5b86258d7 --- /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 000000000..37acddc41 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/exit/tests.rs @@ -0,0 +1,73 @@ +//! 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, + fact_modifier: None, + hook_depth_pct: None, + hook_stated_take_pct: None, + step_lag_ms: 0.0, + stop_anchor: None, + delta_track: None, + bars: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + gap: 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/gap.rs b/crates/moon-core/src/db/tuner/ticks/gap.rs new file mode 100644 index 000000000..a7cbc8262 --- /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 000000000..e8db90492 --- /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/hook.rs b/crates/moon-core/src/db/tuner/ticks/hook.rs new file mode 100644 index 000000000..1556ce1eb --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/hook.rs @@ -0,0 +1,85 @@ +//! 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. 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 +//! 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, 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, +} + +/// 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 000000000..849131867 --- /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/mod.rs b/crates/moon-core/src/db/tuner/ticks/mod.rs index cf313e3d2..74b515ed5 100644 --- a/crates/moon-core/src/db/tuner/ticks/mod.rs +++ b/crates/moon-core/src/db/tuner/ticks/mod.rs @@ -1,15 +1,334 @@ //! 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*` 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; +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 gap; +pub mod hook; +pub mod mshot; +pub mod params; +pub mod record; pub mod scope; +pub mod search; +pub mod settings; +pub mod stats; +pub mod unmodelled; +pub mod verify; +pub use deals::{DealsRead, read_deals}; +pub use entry::{EntryModel, entry_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}; +pub use record::{OwnLines, StopAnchor, entry_placement, fit_for_search, prepare_deal}; pub use scope::{is_service_row, is_tunable}; +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}; + +/// 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: +/// 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) + } +} + +/// 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 delta modifiers read (`MShotAdd*` on the entry corridor, `Add*` +/// 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. 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`) — read by `MShotAdd5sDelta`. + pub d5s: f64, + 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, + /// 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`), 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 +/// 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, + /// 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`]. + 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, + /// `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, + pub sell_price: f64, + /// `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, + /// `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, + /// 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, + /// 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 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>, + /// 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, + /// 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 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 + /// `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, + /// 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 — 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 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 + /// 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, + /// 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 { + /// Whether the position is long; the mirror of every level rule keys off this. + 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) + } + + /// 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 { + 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, /// unless the order waited longer than [`ORDER_WAIT_CAP_MS`] — then its early life is not @@ -29,6 +348,18 @@ pub fn order_open_at(buy_ms: i64, buy_set_ms: Option) -> Option { /// 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 @@ -63,3 +394,259 @@ pub fn model_window_at( .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. +#[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 / PumpMove). + Line, + /// The stop-loss level was crossed: a market exit at the print. + Stop, + /// Nothing closed the position before the tape ran out. Not a trade: excluded from the KPI + /// 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. +#[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`] 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, +} + +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() + } + + /// 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| { + matches!(exit.kind, ExitKind::OpenAtWindowEnd | ExitKind::InGap) + }) + } +} + +/// 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 + /// whose "Entry" group is not being varied. + Fact, + MoonShot(MshotParams), +} + +/// 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 +/// 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 = 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, 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. +/// +/// 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(&pads.focus_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 ([`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. +/// ticks: Prints of the window, ascending. +/// entry: Entry model parameters, or the fact. +/// exit: Sell-line parameters. +/// 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_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(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 { + return Outcome { + fill: None, + exit: None, + profit_pct: None, + }; + }; + // 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 | ExitKind::InGap => 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. +/// +/// 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 + }; + 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 000000000..559a96ce0 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/mshot.rs @@ -0,0 +1,958 @@ +//! 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, 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 `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 +//! 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; +//! - 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 +//! 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. +//! +//! 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. +//! +//! 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. +//! +//! 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::settings::ModelSettings; +use super::verify::archived_replacements; +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`). +#[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, + } + } +} + +/// 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, 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`, + /// `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, +} + +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 and the BTC deltas. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MarketSign { + /// 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), and its BTC terms likewise (the + /// core developer via LinKvo, 2026-09-24: "рыночные и BTC берутся по модулю"). + 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 { + /// `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, + 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, + /// `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, + /// `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, + /// 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 { + /// 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), the market-wide ones as the family reads + /// them ([`MarketSign`]). + pub fn near_addition(&self, d: &Deltas) -> f64 { + let wide = |delta: f64| match self.market_sign { + MarketSign::Signed => delta, + MarketSign::Magnitude => delta.abs(), + }; + 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 + + self.add_3h * d.d3h + + self.add_24h * d.d24h + + self.add_mark * d.dmark + + 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.pricebug_term(d.pricebug) + } + + /// 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; + +/// 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; + +/// 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). +#[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` — 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, + /// `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, +} + +impl MshotParams { + /// Whether two parameter sets are the same strategy — every strategy field equal, whatever + /// the model's settings say. + pub fn same_strategy(&self, other: &Self) -> bool { + *self + == Self { + model: self.model, + ..other.clone() + } + } +} + +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(), + max_modifier: 0.0, + model: ModelSettings::default(), + } + } +} + +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_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 + /// 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 { + /// 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 { + /// 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 } + } + + /// How these parameters want a variant's entry replayed. + pub fn method(&self) -> EntryMethod { + self.params.model.entry_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). + 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) + }; + } + level = snap_to_step(level, tick, deal.is_long()); + } + 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. + /// 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, 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_at(created_ms)); + if fact_far_pct == far_pct { + return Some(fact_level); + } + 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() { + (level + half_step) / (1.0 - far_pct / 100.0) + } else { + (level - half_step) / (1.0 + far_pct / 100.0) + }; + (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 + /// 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, + 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 + self.params.model.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 + /// 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 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], + 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) + } + + /// [`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( + &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; + } + 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.model.latency_ms.max(0.0); + + 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; + let mut hints: Vec<(i64, f64)> = Vec::new(); + // The whole life of the order, when the tape reaches back to its creation. + let created = match deal + .order_open_ms() + .filter(|&created_ms| first_print_ms <= created_ms) + { + 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. + 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; + } + note(created_ms, level); + (None, level, Some((created_ms + latency_ms as i64, 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; + let (_, far_pct) = bounds.at(first.time_ms as i64); + 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..] { + let t_ms = tick.time_ms as i64; + let price = f64::from(tick.price); + 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; + note(hint_ms, level); + } + if let Some((_, level)) = pending.filter(|(apply_at, _)| t_ms >= *apply_at) { + exch_level = Some(level); + pending = None; + } + 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(check) = reference.check() else { + continue; + }; + let (near_pct, far_pct) = bounds.at(t_ms); + 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 { + 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, + }; + // 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); + } + } + } + } + None + } +} + +/// 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 + } +} + +/// 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). +/// +/// - [`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 `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. + recent: std::collections::VecDeque<(i64, f64)>, +} + +impl Reference { + fn new(use_price: UsePrice, is_long: bool, window_ms: i64) -> Self { + Self { + wanted_side: match use_price { + UsePrice::Trade => None, + UsePrice::Ask => Some(Side::Buy), + UsePrice::Bid => Some(Side::Sell), + }, + is_long, + window_ms, + 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); + 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 > self.window_ms) + { + self.recent.pop_front(); + } + } + } + + /// 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 `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 <= self.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()) + } +} + +#[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 000000000..33d71a8d9 --- /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 new file mode 100644 index 000000000..8efa96fd2 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/params.rs @@ -0,0 +1,717 @@ +//! 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, 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. +#[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, +} + +/// 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", + } + } + + /// 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. 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. + Num, + /// `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, + /// 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], + /// 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]; +/// The kinds whose take `SellPrice` does not place: MoonHook's is `HookSellLevel`, Spread's the +/// 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] = &[]; + +/// 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, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotPriceMin", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotUsePrice", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Enum(&["Trade", "ASK", "BID"]), + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotRaiseWait", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotReplaceDelay", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotMinusSatoshi", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Bool, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "FastShotAlgo", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Bool, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAddHourlyDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAdd3hDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAdd15minDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAdd5minDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAdd1minDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAdd24hDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAddMarkDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAddMarketDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAddBTCDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAddBTC5mDelta", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAddPriceBug", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotAddDistance", + group: ParamGroup::Entry, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "SellPrice", + group: ParamGroup::Exit, + section: ParamSection::SellOrder, + 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. + not_kinds: NOT_SELL_PRICE, + }, + TickParam { + key: "MShotSellAtLastPrice", + group: ParamGroup::Exit, + section: ParamSection::StrategySettings, + kind: ParamKind::Bool, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "MShotSellPriceAdjust", + group: ParamGroup::Exit, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: MSHOT, + not_kinds: &[], + }, + TickParam { + key: "HookSellLevel", + group: ParamGroup::Exit, + section: ParamSection::StrategySettings, + kind: ParamKind::Num, + kinds: HOOK, + not_kinds: &[], + }, + TickParam { + key: "SellDelay", + group: ParamGroup::Exit, + section: ParamSection::SellOrder, + kind: ParamKind::Num, + kinds: ANY, + not_kinds: &[], + }, + 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), + 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), + 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`, + // `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), + exit_num("StopLoss", ParamSection::Stops), + exit_bool("UseSecondStop", ParamSection::Stops), + 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), + exit_num("PriceToSwitchStop3", ParamSection::Stops), + exit_num("StopLoss3", ParamSection::Stops), + exit_bool("UseTrailing", ParamSection::Stops), + exit_num("TrailingPercent", ParamSection::Stops), + exit_num("TrailingEMA", ParamSection::Stops), + exit_bool("UseTakeProfit", ParamSection::Stops), + 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), + exit_num("StopLossModifier", ParamSection::DeltaModifiers), + TickParam { + key: "MaxModifier", + group: ParamGroup::Exit, + section: ParamSection::DeltaModifiers, + 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 + // 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) +} + +/// A numeric field of the Exit group every kind understands. +const fn exit_num(key: &'static str, section: ParamSection) -> TickParam { + TickParam { + key, + group: ParamGroup::Exit, + section, + kind: ParamKind::Num, + kinds: ANY, + not_kinds: &[], + } +} + +/// A boolean field of the Exit group every kind understands. +const fn exit_bool(key: &'static str, section: ParamSection) -> TickParam { + TickParam { + key, + group: ParamGroup::Exit, + section, + kind: ParamKind::Bool, + kinds: ANY, + not_kinds: &[], + } +} + +/// 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)) + && !p.not_kinds.contains(&kind) + }) +} + +/// 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 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", + "MaxModifier", + // The stop's trigger (see `ExitParams::fast_stop_loss`): read with the strategy's value, no + // knob. + "FastStopLoss", + // 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", + // The corridor family's one modifier the grid does not offer (no live strategy sets it). + "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", + // SellShot is on only with a distance to keep. + "IgnoreSellShot", + "SellShotDistance", + "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 { + MODEL_ONLY_KEYS.contains(&key) +} + +/// 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, +/// each once: a knob for some kinds is model-only for others (`MaxModifier`). +pub fn param_keys() -> Vec { + 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()) + { + 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 +/// 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, 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), + 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_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), + 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), + // 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), + // 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), + pricebug_cap: MSHOT_PRICEBUG_CAP_PCT, + }, + max_modifier: v.num("MaxModifier", 0.0), + model, + } +} + +/// 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), + 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_5s: 0.0, + 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), + 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, + 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), + 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), + 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 + // 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), + // 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)), + // 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, + } +} + +/// 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; 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 +/// 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("IgnoreSellShot", true) && v.num("SellShotDistance", 0.0) != 0.0 { + Some(UnmodelledRule::SellShot) + } else if !v.bool("IgnoreSellSpread", true) { + Some(UnmodelledRule::SellSpread) + } else { + None + } +} + +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 000000000..49f9c64ab --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/params/range.rs @@ -0,0 +1,507 @@ +//! 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 first = raw.log10().floor() as i32 - 1; + // Past ten decades up there is always 10^power itself, a multiple of any quantum ≤ 1. + 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; + if candidate >= raw * (1.0 - 1e-9) && (multiple - multiple.round()).abs() < 1e-6 { + return candidate; + } + } + } + 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 000000000..65cc17549 --- /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 new file mode 100644 index 000000000..995bcacda --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/params/tests.rs @@ -0,0 +1,29 @@ +//! The knobs and what writing one moves. + +use super::*; + +/// 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/record.rs b/crates/moon-core/src/db/tuner/ticks/record.rs new file mode 100644 index 000000000..add51bf58 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/record.rs @@ -0,0 +1,247 @@ +//! 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::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, +}; +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)] +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, + /// 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. + 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 + /// 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. + /// 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, deal.buy_ms); + // 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 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 { + 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, + second: exit.second_stop, + third: exit.third_stop, + 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 { + // 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() <= 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 + && params.stop_loss_ema == self.ema + && params.second_stop == self.second + && params.third_stop == self.third + } +} + +/// 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 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`]). +/// +/// 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. +/// 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); + // 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()); + // 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`]). +/// +/// 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)?; + // 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, + None => return None, + }; + (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, + 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 000000000..8a4390810 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/record/tests.rs @@ -0,0 +1,295 @@ +//! 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::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 { + 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, + fact_modifier: None, + hook_depth_pct: None, + hook_stated_take_pct: None, + step_lag_ms: 0.0, + stop_anchor: None, + delta_track: None, + bars: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + gap: None, + } +} + +/// A 1 % book stop without latency. +fn book() -> ExitParams { + ExitParams { + stop_loss_pct: -1.0, + fast_stop_loss: false, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + ..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 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(); + 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 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)); +} + +/// 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] +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(), + &Coverage::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.rs b/crates/moon-core/src/db/tuner/ticks/search.rs new file mode 100644 index 000000000..afc85648e --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search.rs @@ -0,0 +1,1265 @@ +//! The search of the "Entry/Exit" axis: coordinate descent with restarts over the discrete +//! 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 +//! 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 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 +//! 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. +//! +//! 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 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 +//! 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`. + +use std::collections::{HashMap, HashSet}; +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::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}; +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, 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)] +pub struct PreparedDeal { + pub deal: Deal, + /// The window's prints, ascending; shared, never copied per evaluation. + pub ticks: Arc<[Tick]>, + /// 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 + /// 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, + /// 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 +/// 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. +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 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. + 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 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. + 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, + /// 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, + /// 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, + /// 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. +#[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 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, + /// 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. +#[derive(Clone, Debug)] +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. + 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. + 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 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. +#[allow(clippy::too_many_arguments)] +fn descend( + mut point: Point, + grids: &Grids, + order: &[&'static TickParam], + pairs: &[&'static TickParam], + coupling: &coupled::Coupling<'_>, + 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; + } + if coupling.inert(field, &point) { + continue; + } + let mut current = point.get(field.key).cloned(); + for index in 0..grids.arity(field) { + let candidate = grids.spell(field, 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(grids, down, &point, start), + grid_index(grids, up, &point, start), + ) else { + continue; + }; + 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, 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; + improved = true; + } else { + restore(&mut point, down.key, was_down); + restore(&mut point, up.key, was_up); + } + } + } + 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(grids, coefficient, term) { + improved |= coupled::walk_path( + &mut point, + (coefficient, term), + &path, + evaluate, + &mut score, + min_n, + handle, + )?; + } + } + } + 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( + grids: &Grids, + field: &TickParam, + point: &Point, + start: &HashMap<&'static str, usize>, +) -> Option { + if field.kind != ParamKind::Num { + return None; + } + match point.get(field.key) { + Some(value) => (0..grids.arity(field)).find(|&i| grids.spell(field, 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, + grids: &Grids, + 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 = 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)) => { + let step = 1 + (next_random(state) % 3) as usize; + if next_random(state).is_multiple_of(2) { + at.saturating_sub(step) + } else { + (at + step).min(n - 1) + } + } + _ => (next_random(state) % n as u64) as usize, + }; + point.insert(field.key, grids.spell(field, 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. +type Point = HashMap<&'static str, String>; + +/// The model parameters a point comes to on a deal whose strategy holds `own`, with `held` laid +/// over it first. +fn params_of( + own: &HashMap, + held: &HashMap, + defaults: &HashMap, + point: &Point, + kind: &str, + model: ModelSettings, +) -> (EntryParams, ExitParams) { + 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()); + } + let sv = StrategyValues { + values: &values, + defaults, + }; + let entry = if entry_model_for(kind) { + EntryParams::MoonShot(mshot_params(&sv, model)) + } else { + EntryParams::Fact + }; + (entry, exit_params(&sv, model)) +} + +/// 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() +} + +/// 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 — 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)) + .filter(|base| !base.trim().is_empty()) + .is_none_or(|base| !same_value(base, value)) + }) + } +} + +/// Every deal's result under one point, in order — `(money, spent)`, `None` where the point +/// makes no trade of the deal — and whether it bought the deal and left it open. +/// +/// 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<(Option<(f64, f64)>, bool)> { + deals + .par_iter() + .zip(of_deal.par_iter()) + .map(|(d, &base)| { + let (entry, exit) = ¶ms[base]; + 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)); + (result, outcome.left_open()) + }) + .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().filter_map(|(result, _)| result) { + tally.push(money); + spent += size; + } + (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, + } +} + +/// 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) +} + +/// 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: +/// 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 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, +) -> 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 + // 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); + } + let closes: Vec = deals.iter().map(|d| d.deal.close_ms).collect(); + let train_n = train_len(&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 max_passes = params.max_passes.max(1); + let model = params.model.sanitized(); + 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. + 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); + // 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), + ); + // 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); + bases.params(params.held, params.defaults, &full, params.kind, model) + }; + let coupling = coupled::Coupling::of(&fields, &per_base_at); + // 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)); + 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. + 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 { + fields + .iter() + .filter(|f| f.group == ParamGroup::Entry && f.kind == ParamKind::Num) + .copied() + .collect() + } 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() + .map(|restart| { + if handle.is_cancelled() { + handle.note_abandoned(); + return None; + } + // 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); + shuffle(&mut order, &mut state); + perturb(&mut point, params.grids, &order, &start, &mut state); + } + 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, + point: walked.point, + score: walked.score, + passes: walked.passes, + converged: walked.converged, + }) + }) + .flatten() + .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 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); + } + } + 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, + 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 + // 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( + &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 { + // 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); + } + // 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)| bases.moves(params.held, key, value)) + .map(|(key, value)| ((*key).to_string(), value.clone())) + .collect(); + values.sort(); + let (holdout, holdout_open) = if train_n < deals.len() { + let per_base = bases.params(params.held, params.defaults, &point, params.kind, model); + let (tally, open) = + closing::tally_counting_open(&deals[train_n..], &bases.of_deal[train_n..], &per_base); + (Some(tally), open) + } 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, + seed, + stats, + }) +} + +/// 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; each is run over its own strategy's values +/// ([`PreparedDeal::own`]). +/// defaults: Schema defaults. +/// kind: The strategy kind. +/// 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], + defaults: &HashMap, + kind: &str, + values: &[(String, String)], + model: ModelSettings, +) -> (Tally, f64) { + 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. +#[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, + /// 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 +/// 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. +/// defaults, kind, values, model: As for [`variant_tally`]. +/// +/// Returns: +/// The modelled outcome and corridor. +pub fn variant_picture( + deal: &PreparedDeal, + defaults: &HashMap, + kind: &str, + values: &[(String, String)], + model: ModelSettings, +) -> VariantPicture { + 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 + // 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(), + }; + // 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` +/// 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, 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], + defaults: &HashMap, + kind: &str, + values: &[(String, String)], + model: ModelSettings, +) -> (Tally, f64, DealResults) { + 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() + .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) + }) + .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) + }) +} + +/// 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()); + } + } + point +} + +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; +#[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 000000000..953d33b90 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/closing.rs @@ -0,0 +1,150 @@ +//! 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::{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; + +/// 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 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( + 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 000000000..5e11ca3ed --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/closing/tests.rs @@ -0,0 +1,141 @@ +//! 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, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), + 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"); +} + +/// 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, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), + 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/coupled.rs b/crates/moon-core/src/db/tuner/ticks/search/coupled.rs new file mode 100644 index 000000000..a079e5c73 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/coupled.rs @@ -0,0 +1,272 @@ +//! 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}; +use crate::db::metrics::Tally; +use crate::db::tuner::threshold_search::SearchHandle; +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 +/// 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( + grids: &Grids, + coefficient: &TickParam, + term: &TickParam, + ) -> Vec> { + 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()) + .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) + } + }; + ( + grids.spell(coefficient, side[at(side.len())]), + grids.spell(term, 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 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())); + 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 000000000..dfd396c8c --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/coupled/tests.rs @@ -0,0 +1,244 @@ +//! 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( + 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()); + 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(), + crate::db::tuner::ticks::search::test_grids::legacy(), + &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(), + crate::db::tuner::ticks::search::test_grids::legacy(), + &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(), + crate::db::tuner::ticks::search::test_grids::legacy(), + &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/deps.rs b/crates/moon-core/src/db/tuner/ticks/search/deps.rs new file mode 100644 index 000000000..16ad4c06e --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/deps.rs @@ -0,0 +1,247 @@ +//! 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 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, 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}; + +/// 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. 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"), + ("usestoploss", "YES"), + ("usesecondstop", "NO"), + ("usestoploss3", "NO"), + ("usetrailing", "NO"), + ("usetakeprofit", "NO"), + ("pricedowntimer", "0"), + ("sellleveldelay", "0"), + ("sellleveltime", "0"), + ("mshotsellatlastprice", "NO"), +]; + +/// 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 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) { + 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(params.grids, &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)>, +} + +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( + grids: &Grids, + fields: &[&'static TickParam], + start: &HashMap<&'static str, usize>, + ) -> Self { + let numbers = fields + .iter() + .filter(|f| f.kind == ParamKind::Num) + .filter_map(|f| Some((*f, grids.spell(f, *start.get(f.key)?)))) + .collect(); + Self { + rules: FieldDeps::bundled(), + numbers, + } + } + + /// `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, + owns: &[&HashMap], + held: &HashMap, + 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 (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) { + 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()); + } + } + } + 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 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. +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 000000000..ed922eb91 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/deps/tests.rs @@ -0,0 +1,285 @@ +//! The search's field dependencies on hand-made strategies. + +use std::collections::HashSet; + +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( + 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. +#[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 — 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")]; + let start: HashMap<&'static str, usize> = [("PriceDownTimer", 3), ("PriceDownPercent", 4)] + .into_iter() + .collect(); + 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]] { + let out = deps.complete(&point(&[]), &owns, &HashMap::new(), &HashMap::new()); + 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( + crate::db::tuner::ticks::search::test_grids::legacy(), + &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, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), + 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( + 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:?}"); + 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| 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( + 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:?}"); + 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/search/nested.rs b/crates/moon-core/src/db/tuner/ticks/search/nested.rs new file mode 100644 index 000000000..7aef6038e --- /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 000000000..d845760fa --- /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 000000000..e1df43b42 --- /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 000000000..7633dc66d --- /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/test_grids.rs b/crates/moon-core/src/db/tuner/ticks/search/test_grids.rs new file mode 100644 index 000000000..9fa1a1da6 --- /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 new file mode 100644 index 000000000..4d3a29a6e --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/search/tests.rs @@ -0,0 +1,843 @@ +//! 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; + +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: Side::Buy, + } +} + +/// 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. +pub(super) fn prepared(uid: i64, peak: f64) -> PreparedDeal { + let deal = Deal { + report_uid: uid, + core_uid: 1, + core_name: String::new(), + strategy_id: 1, + kind: "PumpsDetection".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, + profit: None, + deltas: Deltas::default(), + 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, + stop_anchor: None, + delta_track: None, + bars: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + gap: 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_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", "-50")] + .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 + .last() + .map(|t| (t.time_ms as i64) - d.deal.close_ms) + .unwrap_or(0) +} + +fn base() -> HashMap { + [("SellPrice", "0.2"), ("StopLoss", "-50")] + .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 held = HashMap::new(); + 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 { + held: &held, + defaults: &defaults, + kind: "PumpsDetection", + 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), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + }; + 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 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, + &defaults, + "PumpsDetection", + &result.values, + ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + ); + 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], + &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); + // 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, + &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] +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 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, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), + restarts: 1, + min_n: Some(3), + seed: Some(1), + train_frac: 0.75, + max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + }; + 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 held = HashMap::new(); + let defaults = HashMap::new(); + let all: HashSet = TICK_PARAMS.iter().map(|f| f.key.to_string()).collect(); + let params = SearchParams { + held: &held, + defaults: &defaults, + kind: "PumpsDetection", + 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), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + }; + let handle = SearchHandle::new(); + assert!( + suggest(&deals, ¶ms, &handle).is_err(), + "everything locked" + ); + let none: HashSet = HashSet::new(); + let params = SearchParams { + locked: &none, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), + ..params + }; + let handle = SearchHandle::new(); + handle.cancel(); + assert!(suggest(&deals, ¶ms, &handle).is_err()); + 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)); +} + +/// 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 held = HashMap::new(); + let defaults = HashMap::new(); + let locked = HashSet::new(); + let keys = |method| { + let params = SearchParams { + held: &held, + defaults: &defaults, + kind: "MoonShot", + 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), + train_frac: 1.0, + max_passes: DEFAULT_MAX_PASSES, + keep_corridor: true, + 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")); +} + +#[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, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), + restarts: 3, + min_n: Some(2), + 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"); + assert_eq!( + result.values, + vec![("SellPrice".to_string(), "1".to_string())], + "{result:?}" + ); + assert!( + (result.train.profit - 40.0).abs() < 1e-6, + "{}", + result.train.profit + ); + 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 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 +/// 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, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), + restarts: 3, + min_n: Some(9), + 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_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_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( + 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 { + 10.0 + } else if at == (10, 5) { + 5.0 + } else { + 1.0 + }); + Some(tally) + }; + let order = [price, add]; + let walked = descend( + Point::new(), + crate::db::tuner::ticks::search::test_grids::legacy(), + &order, + &order, + &coupled::Coupling::none(), + &start, + &evaluate, + 1, + DEFAULT_MAX_PASSES, + &SearchHandle::new(), + ) + .expect("not stopped"); + assert_eq!( + ( + 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)) + ); + 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(), + crate::db::tuner::ticks::search::test_grids::legacy(), + &order, + &[], + &coupled::Coupling::none(), + &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, + 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( + crate::db::tuner::ticks::search::test_grids::legacy(), + 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, + grids: crate::db::tuner::ticks::search::test_grids::legacy(), + 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:?}"); +} + +/// 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/settings.rs b/crates/moon-core/src/db/tuner/ticks/settings.rs new file mode 100644 index 000000000..0c431a2e1 --- /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`, `exit::stops::TICKER_PERIOD_MS`, `verify::POINT_TIME_TOLERANCE_MS`, …). + +use serde::{Deserialize, Serialize}; + +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, +}; +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 000000000..e6a8c5189 --- /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/stats.rs b/crates/moon-core/src/db/tuner/ticks/stats.rs new file mode 100644 index 000000000..3ed93a8db --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/stats.rs @@ -0,0 +1,37 @@ +//! 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) +} + +/// 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/stats/tests.rs b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs new file mode 100644 index 000000000..fee6d73cc --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/stats/tests.rs @@ -0,0 +1,52 @@ +use super::*; +use crate::db::tuner::ticks::Deltas; + +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(), + 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, + profit: None, + deltas: Deltas::default(), + 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, + stop_anchor: None, + delta_track: None, + bars: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + gap: 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 new file mode 100644 index 000000000..1c3f1e4ba --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/tests.rs @@ -0,0 +1,2084 @@ +//! The model on synthetic tapes: every rule of the spec's §8, one print at a time. + +use std::collections::HashMap; + +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; +use super::*; +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 { + 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() +} + +pub(super) fn deal() -> Deal { + Deal { + report_uid: 1, + core_uid: 7, + core_name: String::new(), + 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(), + fact_pnl: 10.0, + profit: None, + deltas: Deltas::default(), + 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, + stop_anchor: None, + delta_track: None, + bars: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + gap: 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); +} + +#[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] +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 { + model: ModelSettings { + latency_ms: 0.0, + ..ModelSettings::default() + }, + ..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); +} + +/// 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] +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 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, ModelSettings::default()).sell_mods; + assert!((sell.near_addition(&d) - 0.5).abs() < 1e-9); + 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")); +} + +#[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); +} + +/// 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, + model: ModelSettings { + entry_method: EntryMethod::Shift, + ..ModelSettings::default() + }, + ..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 { + model: ModelSettings { + entry_method: EntryMethod::Shift, + ..ModelSettings::default() + }, + ..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 { + model: ModelSettings { + entry_method: EntryMethod::Model, + ..ModelSettings::default() + }, + ..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). +#[test] +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, + replace_delay_s: 0.08, + 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 + // 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), + (1_050, 99.6), + (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.price - f64::from(99.40_f32) * 0.99).abs() < 1e-9, + "{fill:?}" + ); +} + +/// 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 { + 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 + // 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] +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, PRE_SPIKE_LOOKBACK_MS), + Some(101.0) + ); + assert_eq!( + pre_spike_price(&ticks, PRE_SPIKE_LOOKBACK_MS - 1, PRE_SPIKE_LOOKBACK_MS), + None + ); +} + +#[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 { + 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::OpenAtWindowEnd); + assert_eq!(out.t_ms, 25_000, "the tape's end"); +} + +#[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)); +} + +/// 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.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 - 100.0).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, + ); + // 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] +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, + 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_judges_the_exit_from_the_fact() { + 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, + None, + ); + assert_eq!(v.entry, Some(false)); + assert_eq!(v.fill, None); + // 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, 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), + (20_000, 99.5), + (25_000, 99.5), + ]); + let v = verify( + &deal(), + &ticks, + &EntryParams::Fact, + &ExitParams::default(), + None, + None, + ); + assert_eq!(v.entry, None); + 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)]); + let v = verify( + &d, + &ticks, + &EntryParams::MoonShot(mshot()), + &ExitParams::default(), + None, + 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_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, + // 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, + None, + ); + assert_eq!(v.exit_kind, Some(ExitKind::Take)); + assert_eq!(v.exit, None); + 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:?}"); +} + +/// 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)); + 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, + }, + ModelSettings::default(), + ); + 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, + }, + 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); +} + +/// 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, + }, + ModelSettings::default(), + ) + }; + 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(); + for key in [ + "MShotPrice", + "MShotPriceMin", + "MShotUsePrice", + "MShotRaiseWait", + "MShotReplaceDelay", + "MShotMinusSatoshi", + "MShotAddHourlyDelta", + "MShotAddPriceBug", + "MShotAddDistance", + "SellPrice", + "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 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, "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::sell_order::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" + ); + 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")); +} + +// ---- 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); +} + +// ---- 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), + step_lag_ms: 0.0, + stop_anchor: None, + delta_track: None, + bars: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + ..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 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 { + 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 - 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 +/// 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())); + // The kinds that take by `SellPrice` have it — with or without an archive. + assert!(model.take_known(&deal()), "MoonShot"); + for kind in ["PumpsDetection", "Combo"] { + let d = Deal { + kind: kind.into(), + ..deal() + }; + assert!(model.take_known(&d), "{kind} takes by SellPrice"); + } + // 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), + fact_modifier: None, + ..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), + fact_modifier: None, + ..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, + fact_modifier: 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())); +} + +/// 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_coefficient_takes_the_sell_under_its_floor() { + let mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; + let params = ExitParams { + sell_price_pct: 1.0, + sell_modifier: -0.5, + sell_mods: mods, + ..ExitParams::default() + }; + let deal_at = |d1h: f64| Deal { + deltas: Deltas { + d1h, + ..Deltas::default() + }, + ..deal() + }; + let fill = Fill { + t_ms: 10_000, + price: 100.0, + }; + // Σ = |±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. +#[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 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")); +} + +/// 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 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.clone() + }; + 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_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 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 mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; + 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, 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, 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, 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, d.buy_ms) - -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 mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; + 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, far.buy_ms), + 0.0, + "no stop, not a near one" + ); + // 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() + }; + let down = Deal { + deltas: Deltas { + d1h: -70.0, + ..Deltas::default() + }, + ..deal() + }; + 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 { + d1h: 2.0, + ..Deltas::default() + }, + ..deal() + }; + 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 { + 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, up.buy_ms) - 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 mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; + 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, 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 + // 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, the adjusted distance included (`exit::level_off_buy` +/// divides the fill by it). +#[test] +fn a_short_stop_sits_above_with_the_modifier() { + let mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; + 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 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)]), + Fill { + t_ms: 10_000, + price: 100.0, + }, + ); + assert_eq!(walk.exit.kind, ExitKind::Stop, "the price crossed 103.09"); + 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 mods = Modifiers { + add_1h: 1.0, + ..Modifiers::default() + }; + 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, + }; + // The 101 of SellPrice, then 5 % * 0.2 = 1 % higher. + let take = ExitModel::new(¶ms).take_level(&d, &[], fill); + 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.404).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}"); +} + +/// 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-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 000000000..c3910ad83 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data.rs @@ -0,0 +1,1351 @@ +//! 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 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 variables are read HERE only, in a test a developer runs by hand (`MOON_TICKS_COIN` +//! 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; `MOON_TICKS_VARIANT="Key=value,…"` replays each deal under those values laid +//! 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; +use std::sync::mpsc; +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}; +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::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); + +/// 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, 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, false); + }; + match entries.get(&deal.report_uid) { + Some(TraceEntry::Lines(lines)) => { + let entry = lines + .iter() + .find(|l| l.own && l.kind == ArchivedLineKind::Entry) + .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) + .map(|l| l.points.iter().map(|&(t, p)| (t as i64, p)).collect()); + (entry, exit, true) + } + _ => (None, None, false), + } +} + +/// 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: spans.clone(), + reply, + }); + rx.recv_timeout(Duration::from_secs(10)) + .map(|answer| (answer.ticks, answer.covered)) + .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 +/// Entry and Exit lines, the entry order's creation, placement and saved corridor, the MoonShot +/// 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, + deal: &Deal, + values: &HashMap, + ticks: &[Tick], + held: &super::super::exit::line::LineWalk, + 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::stops::stop_pct(exit, deal, deal.buy_ms); + 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)); + 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, + "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)], + "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, + "buy_set_ms": deal.buy_set_ms, + "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, + "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)); + 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.model.latency_ms, + }) + } + _ => serde_json::Value::Null, + }, + }); + 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, + }, + ModelSettings::default(), + ); + 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() +} + +/// 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; + } +} + +/// 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, + }, + ModelSettings::default(), + ); + 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 { + 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) +} + +/// 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); + } + } + } + out +} + +/// 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!( + " {: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}")), + ); + } +} + +#[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))); + // 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 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!( + "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(), + read.without_ms + ); + + // 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. 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(); + // `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. + 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. + 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 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); + 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); + // 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); + // 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(); + // 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); + 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 + // 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 { + 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) = model_window(&deal, margin_ms, long_position_ms()) else { + continue; + }; + 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. + let Some(venue) = venue_of_core.get(&deal.core_uid) else { + no_venue += 1; + continue; + }; + for (exchange, market) in pairs + .iter() + .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); + } + } + 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(&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); + let sv = StrategyValues { + 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) { + EntryParams::MoonShot(mshot_params( + &sv, + ModelSettings { + latency_ms: core_latency, + ..ModelSettings::default() + }, + )) + } else { + EntryParams::Fact + }; + let mut exit = exit_params(&sv, ModelSettings::default()); + if std::env::var_os("MOON_TICKS_LATENCY_EXIT").is_some() { + 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()) { + let btc = btc_of_exchange.get(exchange).map(String::as_str); + 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()); + 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 { + deal.delta_track = track; + } + } + prepare_deal( + &mut deal, + &entry, + &exit, + OwnLines { + entry: entry_line.as_deref(), + 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. + if let (Some((down, up)), EntryParams::MoonShot(params)) = (deal.corridor, &entry) { + 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) + } 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 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), + model: ModelSettings { + entry_method: method, + ..own.model + }, + ..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 { + model: ModelSettings { + entry_method: EntryMethod::Shift, + ..p.params.model + }, + ..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() + && 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, + 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)); + } + // 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 = verify::fact_sell_start(&deal, &exit, exit_points.as_deref()); + 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, + &deal, + &values, + &ticks, + &held, + exit_points.as_deref(), + entry_line.as_deref(), + &entry, + &exit, + ); + } + eprintln!( + " held exit {:?} at {:+}ms of close · stop {:.3}% · model pts {}", + held.exit.kind, + held.exit.t_ms - deal.close_ms, + super::super::exit::stops::stop_pct(&exit, &deal, deal.buy_ms), + 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, + &ticks, + &entry, + &exit, + entry_line.as_deref(), + exit_points.as_deref(), + ); + 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:?} | hook depth {hook_depth:?} core {hook_stated:?} model {hook_model:?}", + 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_line.as_ref().and_then(|l| l.first()), + 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), + line = archived.line_points, + 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 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)) + ), + ); + // 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()); + 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") { + 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 {:?}", + own.exit.map(|e| e.kind), + round3(own.profit_pct), + 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) { + 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 + // 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.entry + }; + 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; + 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} · \ + 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}"); + 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() + ); + } + 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())); + 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:?}"); + 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, + fact_sum / own_n.max(1) as f64, + ); +} 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 000000000..82572b7e7 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/tests/real_data/search.rs @@ -0,0 +1,415 @@ +//! `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). +//! +//! 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; +//! `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; +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, 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; + +/// 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())); + // `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); + } +} + +/// 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 restarts = std::env::var("MOON_TICKS_SEARCH_RESTARTS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10); + 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, + kind, + vary_entry: false, + vary_exit: true, + locked: &locked, + grids: &grids, + 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), {}, {restarts} restart(s), {} grids, {elapsed} ms", + deals[0].deal.strategy_id, + deals[0].deal.core_name, + deals.len(), + if whole_exit { + "whole exit" + } else { + "Delta Modifiers only" + }, + if legacy { "legacy" } else { "auto" } + ); + 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:?}"), + } +} + +/// `MOON_TICKS_SEARCH_ENTRY=`: 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 { + 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-core/src/db/tuner/ticks/tests/required.rs b/crates/moon-core/src/db/tuner/ticks/tests/required.rs new file mode 100644 index 000000000..d09432157 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/tests/required.rs @@ -0,0 +1,138 @@ +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}; + +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 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!( + 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 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. +#[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(&window); + assert_eq!( + required.spans(), + &[ + (buy - RUN_UP_MS, buy + RUN_UP_MS), + (close - TAIL_MS, close + TAIL_MS) + ] + ); + assert!(spans.covers(&required)); + let close = buy + 20_000; + 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!( + model_window(&odd, MINUTE_MS, 60 * MINUTE_MS) + .expect("window") + .open_ms, + buy + ); +} 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 000000000..cb5e58ffa --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/unmodelled.rs @@ -0,0 +1,344 @@ +//! 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 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 +//! (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). +//! +//! 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. +#[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. 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"), + // 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 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. +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 = switched_on(w.key, value, &default, &effective, deps) + && also_holds(w.also, values, defaults); + on.then(|| UnmodelledField { + key: w.key, + value: value.clone(), + section: w.section, + rule: w.rule, + }) + }) + .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, + 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. +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; + } + 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 000000000..75395ed30 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/unmodelled/tests.rs @@ -0,0 +1,331 @@ +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(&[ + ("UseBV_SV_Stop", "YES"), + ("PanicSellDelisted", "YES"), + ("SellByFilters", "30"), + ("IgnoreSellSpread", "NO"), + ]); + let found = unmodelled_fields(&v, &HashMap::new(), &FieldDeps::bundled()); + assert_eq!( + keys(&found), + [ + "UseBV_SV_Stop", + "PanicSellDelisted", + "SellByFilters", + "IgnoreSellSpread" + ] + ); + 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: `SellByFilters` means nothing with `AutoSell` off. +#[test] +fn a_field_under_a_switch_that_is_off_is_not_in_effect() { + 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() + )), + ["SellByFilters"] + ); +} + +/// `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(&[("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 + ); + } +} + +/// 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-core/src/db/tuner/ticks/verify.rs b/crates/moon-core/src/db/tuner/ticks/verify.rs new file mode 100644 index 000000000..4df5eb357 --- /dev/null +++ b/crates/moon-core/src/db/tuner/ticks/verify.rs @@ -0,0 +1,758 @@ +//! 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; 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, 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 +//! 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 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 +//! 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, 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; +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::mshot::MshotParams; +use super::settings::ModelSettings; +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 +/// 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 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 +/// 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 +/// 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 { + /// 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 — 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) — 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 — 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, + /// 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. +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_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( + deal: &Deal, + ticks: &[Tick], + entry: &EntryParams, + exit: &ExitParams, + 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), + (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() <= entry_tolerance_pct(params, deal)); + (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. + // 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 — 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. + 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 = 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()) + .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 model = &exit.model; + let closed = match walked.exit.kind { + ExitKind::Stop + if walked.exit.t_ms <= deal.close_ms + model.point_time_ms || fact_stopped => + { + 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. + 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 + model.latency_whole_ms(); + let level = archive + .as_ref() + .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 { + t_ms: deal.close_ms, + price: level.price, + kind: if modelled + .first() + .is_some_and(|first| first.t_ms < level.t_ms) + { + 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, + } + } + }; + // 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. + // 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 + || (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`). + (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 { + (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, + 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; + // 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 <= 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, 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 + // 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 <= model.point_time_ms + && deviation_pct(closed.price, level.1) + .is_some_and(|d| d.abs() <= model.price_pct) + }); + let price_ok = dev.is_some_and(|d| { + d.abs() <= tolerance + || (corroborated && improved(d)) + || (level_reproduced && better_by(d) >= -tolerance) + }); + (Some(price_ok && line_ok), dev, points) + } else { + (None, None, None) + }; + Verdict { + entry: entry_ok, + entry_dev_pct: entry_dev, + exit: exit_ok, + exit_dev_pct: exit_dev, + fill: outcome.fill, + exit_kind: Some(closed.kind), + line_points, + } +} + +/// 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 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. +/// +/// 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. +/// 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, 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, model) != 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 - model.point_time_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 +/// 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() <= exit.model.price_pct); + if !at_sale { + return false; + } + 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()); + at_close || fill_side +} + +/// 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. +/// +/// 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 deltas the model only partly re-reads live (see +/// `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 +/// 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. +/// 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 = 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; + let points = exit_points.filter(|p| !p.is_empty()).map(|archived| { + let mut moves = archived_replacements(archived); + 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 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 + }; + 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() <= 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 + // 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), + } +} + +/// 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(|| level_off_buy(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 { + 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` — +/// 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, as they stood +/// 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(params.model.price_pct) +} + +/// 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 +} + +/// 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)], model: &ModelSettings) -> usize { + let modelled: Vec<&LinePoint> = modelled.iter().collect(); + matched_points_of(&modelled, archived, model) +} + +/// [`matched_points`] over borrowed points. +fn matched_points_of( + modelled: &[&LinePoint], + archived: &[(i64, f64)], + model: &ModelSettings, +) -> usize { + archived + .iter() + .filter(|&&point| modelled.iter().any(|m| same_move(m, point, model))) + .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; 2] = ["Auto Price Down", "Sell Level"]; + +/// 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 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); + match kind { + 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 | ExitKind::InGap => false, + } +} + +/// 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. +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)) +} + +/// 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)) +} diff --git a/crates/moon-core/src/diagnostics/filter.rs b/crates/moon-core/src/diagnostics/filter.rs index fbd732ec9..56511355b 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 c5a26c6db..2d77f33e5 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 bed7f2921..c409d2b81 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/feed/mod.rs b/crates/moon-core/src/feed/mod.rs index a972f4850..c438ab91b 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 000000000..c2444aba1 --- /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 000000000..11d94a63a --- /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-core/src/market/source/read.rs b/crates/moon-core/src/market/source/read.rs index d944b3380..7df82fae3 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 @@ -508,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-core/src/market/trade_replay/mod.rs b/crates/moon-core/src/market/trade_replay/mod.rs index 2b2ce7f6b..d98632c55 100644 --- a/crates/moon-core/src/market/trade_replay/mod.rs +++ b/crates/moon-core/src/market/trade_replay/mod.rs @@ -44,7 +44,7 @@ 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, + set_margin_s, set_tape_autoload, tape_autoload, }; pub use worker::{TickAnswer, TickQuery, query_held}; @@ -291,8 +291,8 @@ impl ReplayIntent { /// 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 + /// 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 { diff --git a/crates/moon-core/src/market/trade_replay/settings.rs b/crates/moon-core/src/market/trade_replay/settings.rs index b0178f599..cca6f15ed 100644 --- a/crates/moon-core/src/market/trade_replay/settings.rs +++ b/crates/moon-core/src/market/trade_replay/settings.rs @@ -11,6 +11,8 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; /// 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_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); @@ -24,13 +26,15 @@ fn init() { INIT.get_or_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, long position from {} min, cleanup at startup {}", + "[x] trade-replay settings: margin {} s, long position from {} min, tape autoload {}, cleanup at startup {}", cfg.trade_replay.margin_s, cfg.trade_replay.long_position_min, + cfg.trade_replay.autoload_missing, cfg.trade_replay.cleanup_at_startup ); }); @@ -58,6 +62,19 @@ pub fn set_margin_s(secs: u32) { ); } +/// 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(); + TAPE_AUTOLOAD.load(Ordering::Relaxed) +} + +/// Move the live autoload switch; the Storage tab writes the file beside this. +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 diff --git a/crates/moon-core/src/market/trade_replay/worker.rs b/crates/moon-core/src/market/trade_replay/worker.rs index 440a9125d..635fc89b3 100644 --- a/crates/moon-core/src/market/trade_replay/worker.rs +++ b/crates/moon-core/src/market/trade_replay/worker.rs @@ -1532,9 +1532,9 @@ fn tick_stage_for( /// 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. +/// 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. diff --git a/crates/moon-core/tests/diagnostics_contract.rs b/crates/moon-core/tests/diagnostics_contract.rs index 7ce2640c0..7b57fbddc 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 8050840ce..8774b3eeb 100644 --- a/crates/moon-ui-gpui/src/analytics/bg.rs +++ b/crates/moon-ui-gpui/src/analytics/bg.rs @@ -29,6 +29,14 @@ 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' 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. @@ -119,20 +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, - ]); + 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. @@ -144,6 +165,10 @@ impl AnalyticsView { ReadLane::CoinKpi, ReadLane::CoinPicked, ReadLane::Time, + ReadLane::Ticks, + ReadLane::TicksReplay, + ReadLane::TicksVariants, + ReadLane::TicksSearch, ]); } diff --git a/crates/moon-ui-gpui/src/analytics/mod.rs b/crates/moon-ui-gpui/src/analytics/mod.rs index 1e4b0931d..c4ac9c131 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::{ @@ -622,11 +624,18 @@ 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 /// 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, @@ -867,6 +876,14 @@ 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); + 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. let saved_strat_sort = @@ -1063,6 +1080,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, @@ -1072,6 +1090,7 @@ impl AnalyticsView { saved_tuner_compose, ), coins: tuner::CoinsState::load(saved_coin_sort), + ticks, coin_lists: tuner::CoinListsState::default(), time_tuner: tuner::TimeTunerState::load(), cal_from, @@ -1135,7 +1154,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 @@ -1151,8 +1170,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. @@ -1168,10 +1189,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_for_axis(); self.coin_lists.invalidate(); self.mark_report_data_stale(); self.request_report_refresh(RefreshUrgency::Writer, false, cx); @@ -1230,6 +1252,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 +1697,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(); @@ -2360,7 +2384,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 315da02e7..1f5c0ba15 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/filter/mod.rs b/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs index 1e7b6c1b5..ceb9a74a9 100644 --- a/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs +++ b/crates/moon-ui-gpui/src/analytics/tuner/filter/mod.rs @@ -83,43 +83,20 @@ 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. /// /// Returns: /// Lowercase field names mapped to their first available core-schema default. - fn filter_defaults(&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 + pub(in crate::analytics::tuner) fn filter_defaults( + &self, + cx: &Context, + ) -> HashMap { + 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/filter/state.rs b/crates/moon-ui-gpui/src/analytics/tuner/filter/state.rs index 80da6f5e5..0de853622 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/kpi.rs b/crates/moon-ui-gpui/src/analytics/tuner/kpi.rs index 34d3e0833..60a0a4272 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/list/table.rs b/crates/moon-ui-gpui/src/analytics/tuner/list/table.rs index dcd357809..d04c3681a 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 c7b06a21a..cfd17458b 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. +pub(in crate::analytics) 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 @@ -74,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) @@ -109,14 +112,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 +137,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 +284,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 +386,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; } @@ -396,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 @@ -424,6 +461,7 @@ impl AnalyticsView { }); match pick { Some(sel) => { + self.sel_extra = extras; self.set_sel_strategy(Some(sel), cx); true } @@ -481,6 +519,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,11 +684,18 @@ 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, 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 // 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() @@ -663,6 +709,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 — 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"), } @@ -706,7 +762,39 @@ impl AnalyticsView { .child(pick), ); } - StratMode::Filters | StratMode::Coins => {} + 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; 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)) + .flex_none() + .h_full() + .min_h_0() + .child(side), + ); + } + 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 +821,7 @@ impl AnalyticsView { } StratMode::Time => self.reload_time(cx), StratMode::Coins => self.reload_coins(cx), + StratMode::Ticks => self.reload_ticks(cx), } } @@ -752,6 +841,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 +857,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 +881,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 600e2a577..b29ba9403 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 6b83a8035..615a35bdd 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. @@ -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", } } @@ -89,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 { @@ -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(); }); @@ -190,6 +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 => this.ticks_open_copy_dialog(window, cx), } cx.notify(); })) @@ -206,6 +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(), + // 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 { @@ -220,6 +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 => this.ticks_open_save_dialog(cx), } cx.notify(); })) @@ -247,6 +255,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::Ticks => self.ticks_config_row(p, window, cx), TunerKind::Coins => div().into_any_element(), } } @@ -837,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, @@ -849,6 +858,7 @@ impl AnalyticsView { let cached = match kind { TunerKind::Filter => self.tuner.inputs.get(id), TunerKind::Time => self.time_tuner.inputs.get(id), + TunerKind::Ticks => self.ticks.inputs.get(id), TunerKind::Coins => None, }; if let Some(state) = cached { @@ -859,8 +869,11 @@ 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(), + (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(); @@ -915,6 +928,17 @@ impl AnalyticsView { this.time_tuner.min_trades = value; this.time_tuner.invalidate_suggest(); } + (TunerKind::Ticks, CfgInput::Restarts) => { + this.ticks.iters = value; + this.persist_ticks_settings(cx); + } + (TunerKind::Ticks, CfgInput::MinTrades) => { + this.ticks.min_trades = value; + } + (TunerKind::Ticks, CfgInput::Seed) => { + this.ticks.seed = value; + this.persist_ticks_settings(cx); + } (TunerKind::Time, _) | (TunerKind::Coins, _) => {} } if !matches!(ev, MoonInputEvent::Change) { @@ -927,11 +951,24 @@ 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::Ticks => self.ticks.inputs.insert(id.to_string(), state.clone()), TunerKind::Coins => None, }; 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 { @@ -957,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/strat_columns.rs b/crates/moon-ui-gpui/src/analytics/tuner/strat_columns.rs index d9df8b18d..70672caba 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 ec3f5bbde..e47e9d299 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/accuracy.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/accuracy.rs new file mode 100644 index 000000000..49b154cbb --- /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 000000000..357449fdf --- /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/cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs new file mode 100644 index 000000000..671999fdf --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/cfg.rs @@ -0,0 +1,818 @@ +//! 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, MoonCheckbox, 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 } => { + let progress = t!( + "analytics.tuner.sugg_progress", + done = handle.completed(), + total = total + ) + .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) { + (Some(note), _) => (note.clone(), p.amber), + (None, Some(result)) => ( + super::variants::search_stats_line(&result.stats), + p.text_muted, + ), + (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, + CfgInput::Restarts, + &placeholder, + window, + cx, + ); + let settings = self.ticks_search_settings(p, window, cx); + let (one_tip, all_tip) = self.ticks_search_tips(); + 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, _, window, cx| { + this.ticks_suggest_one(window, cx) + }), + ) + .render(), + ), + ) + .child( + 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() + .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, + ); + // 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( + 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_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, + 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( + 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, + 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 + .children(self.ticks_tail_rows(p, window, cx)) + .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. +pub(super) 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. +pub(super) 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/columns.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs new file mode 100644 index 000000000..ca2be45e8 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/columns.rs @@ -0,0 +1,130 @@ +//! 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. 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 { + /// 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_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"; +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: 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, + "analytics.ticks.col.result", + 58.0, + 48.0, + Align::Right, + ), + col( + COL_PROFIT, + "analytics.ticks.col.profit", + 96.0, + 60.0, + Align::Right, + ), + col( + COL_PLAN, + "analytics.ticks.col.plan", + 104.0, + 60.0, + Align::Right, + ), + col( + COL_DURATION, + "analytics.ticks.col.duration", + 52.0, + 44.0, + Align::Right, + ), + col( + COL_HELD, + "analytics.ticks.col.held", + 76.0, + 60.0, + Align::Center, + ), + 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/delta_summary.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary.rs new file mode 100644 index 000000000..19ca4e17a --- /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 000000000..c360b430f --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/delta_summary/tests.rs @@ -0,0 +1,70 @@ +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, + bars: None, + 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, + stop_anchor: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + gap: 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/estimate.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs new file mode 100644 index 000000000..322f4daec --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/estimate.rs @@ -0,0 +1,437 @@ +//! 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 + /// 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, + 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) { + // 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() + .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; + 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 000000000..48e09708b --- /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/fetch.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs new file mode 100644 index 000000000..bfe70c971 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch.rs @@ -0,0 +1,334 @@ +//! "Fetch the tape" — the view's side of the batch that [`job`] runs. +//! +//! 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. 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; + +use gpui::*; + +use super::super::super::AnalyticsView; +use super::state::{RowAddress, RowEdit, 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, margin_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>>, + /// Per core, BTC's market on its exchange — the BTC deltas are read off it. + btc_markets: HashMap>, +} + +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(), + btc_markets: 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 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) + .ok() + .and_then(|address| { + let market = self + .source + .resolve_market(deal.core_uid, quote, &deal.coin)?; + Some(Arc::new(RowAddress { + core_uid: deal.core_uid, + venue: address.venue, + exchange_key: address.exchange_key, + market, + btc_market, + })) + }); + self.addresses.insert(key, address.clone()); + address + } + + /// 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 = model_window(&deal, margin_ms(), long_position_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: 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 self.ticks.tape_reading { + return; + } + let Some(data) = self.ticks.data.data() else { + return; + }; + let backend = self.backend.read(cx); + let resolver = FetchResolver::of(backend); + let mut rows: Vec = data + .fetchable() + .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 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(); + } + + /// 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" + ); + } + } + + /// 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 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(); + autoload::cancel(); + self.ticks.edit_rows( + in_flight + .into_iter() + .flat_map(|(uids, _)| uids) + .map(|uid| (uid, RowEdit::UnmarkFetching)), + ); + cx.notify(); + } + + /// 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 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, + } + // 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); + } + // 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| { + this.apply_fetch_events(events, cx); + cx.notify(); + }) + }); + // The view is gone; the job goes on without a listener. + if applied.is_err() { + break; + } + // 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; + } + } + listening.store(false, Ordering::Relaxed); + })); + } + + /// 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); + } + } + + /// 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) { + 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 new file mode 100644 index 000000000..6c97d2bc7 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload.rs @@ -0,0 +1,384 @@ +//! 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`]) — 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. +//! +//! 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. + +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use gpui::App; + +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, 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::{ + 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. +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, + }; + } + // 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 && crate::settings::trades_cleanup_startup::clear_for_autoload() => { + 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 model_window(&deal, margin_ms(), long_position_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); + } + 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(); + // 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), {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( + 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/autoload/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/autoload/tests.rs new file mode 100644 index 000000000..8b78b301d --- /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 new file mode 100644 index 000000000..4681c6a08 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job.rs @@ -0,0 +1,839 @@ +//! 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. The startup autoload ([`enqueue`]) adds rows +//! to the same queue; a batch is whatever is in it, wherever it came from. +//! +//! 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 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 +//! 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, HashSet}; +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::{ + 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 +/// 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 { + 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, + /// 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 { + 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, + continued: 0, + covered_ms: 0, + priority: 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 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. +struct Deferred { + row: QueuedRow, + 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: Vec, + 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_empty() || !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 + } + + /// 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 { + 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"); + }); +} + +/// 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: +/// How many rows were added. +pub(in crate::analytics::tuner) fn enqueue( + rows: Vec, + defaults: HashMap, +) -> usize { + if rows.is_empty() { + return 0; + } + ensure_thread(); + let job = job(); + let mut st = lock(job); + if !st.active() { + st.total = 0; + st.done = 0; + 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(); + 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, 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(); + for out in &st.in_flight { + out.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 + .iter() + .map(|f| (f.uids.clone(), f.market.clone())) + .collect(), + } +} + +/// 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) +} + +/// 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 — 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) 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 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. +/// +/// 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, + long_position_ms: i64, +) -> Vec { + let anchor = rows[seed]; + let mut taken = vec![seed]; + 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() { + if taken.contains(&index) + || row.exchange_key != anchor.exchange_key + || row.market != anchor.market + { + continue; + } + 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_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_open = 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. +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: 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 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, + open_ms: row.window.open_ms, + close_ms: row.deal.close_ms, + margin_ms: row.window.margin_ms, + }) + .collect(); + // 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() + .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)); + } + 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 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 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.window.open_ms) + .min() + .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_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. + 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 { + 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(), + 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; + // 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) => { + 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 } + } + // 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.retain(|f| f.uids != uids); + st.notify(JobEvent::Progress); + drop(st); + job.wake.notify_all(); + return; + } + 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_line: None, + held: None, + }; + replay_row( + &mut answer, + defaults, + super::super::model_cfg::current(), + 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. + 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; + } + // 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), + }; + } + 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 +/// 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 000000000..8b6a46330 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/fetch/job/tests.rs @@ -0,0 +1,211 @@ +// 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::{ + ClusterKey, MAX_CONTINUATIONS, MAX_IN_FLIGHT, continues, pick_cluster, pick_dispatchable, + 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); +} + +/// 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 +/// 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, open_ms, close_ms| ClusterKey { + exchange_key, + market, + open_ms, + close_ms, + margin_ms: 30 * SEC, + }; + let base = 1_000_000 * SEC; + let rows = [ + // 0: another market, same minute — never joins. + 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 + 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 + 10 * SEC), + ]; + const _: () = assert!( + 140 * SEC + 30 * SEC < LONG_POSITION_MS, + "the cluster stays short" + ); + assert_eq!(pick_cluster(&rows, 3, LONG_POSITION_MS), vec![1, 2, 3]); + assert_eq!( + pick_cluster(&rows, 0, LONG_POSITION_MS), + vec![0], + "a lone row is its own cluster" + ); + // Overlapping rows whose hull would pass a long position's length: the hull stops growing. + let long = [ + 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_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 +/// 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/grid.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs new file mode 100644 index 000000000..c45d69a8f --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/grid.rs @@ -0,0 +1,792 @@ +//! 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, 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 +//! 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 +//! 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::*; +use moon_ui::{ + MoonCheckbox, MoonInput, MoonInputEvent, MoonInputState, MoonPalette, h_flex, v_flex, +}; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +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, 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 + /// sections, scrolling. + pub(in crate::analytics::tuner) fn ticks_grid( + &mut self, + p: MoonPalette, + 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 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(&[], &[], &Default::default()).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(live, p, cx)); + // 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; + 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; + // 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() + .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) => { + 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), + }); + } + } + // 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); + // 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() + .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(grid), + ) + .children(estimate) + .children(accuracy) + .into_any_element() + } + + /// 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 { + self.ticks.locked.remove(*key); + } else { + self.ticks.locked.insert((*key).to_string()); + } + } + self.persist_ticks_settings(cx); + cx.notify(); + } + + /// 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 ✕ · + /// from · to · step and the reset of every range. + fn ticks_grid_header( + &self, + keys: Vec<&'static str>, + p: MoonPalette, + cx: &mut Context, + ) -> AnyElement { + let (all_on, some_on) = self.ticks_tick_state(&keys); + let cell = |text: String| { + div() + .w(design::font_w_px(cx, CELL_W)) + .flex_none() + .text_center() + .truncate() + .child(text) + }; + let mut head = h_flex() + .w_full() + .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)) + .child( + 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(); + 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. 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() + .child(t!("analytics.tuner.field").to_string()) + .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())); + 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() + } + + /// 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, + d: &TicksData, + p: MoonPalette, + ) -> 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"), + }; + // 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!( + "{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: &mut 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(); + // 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 + // 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) => { + 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 => {} + } + let color = if notes.iter().any(|(_, c)| *c == p.orange) { + p.orange + } else { + p.text_muted + }; + let note = (!notes.is_empty()).then(|| { + let text = notes + .iter() + .map(|(text, _)| text.as_str()) + .collect::>() + .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; + 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)) + .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)) + .font_family(design::ui_font()) + .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(if modelled { p.text } else { p.text_muted })) + .child(title) + .on_click( + cx.listener(move |this, _, _, cx| this.ticks_toggle_section(which, cx)), + ), + ) + .when_some(note, |el, (note, tip)| { + el.child( + div() + .id(SharedString::from(format!("an-ticks-sec-note-{id}"))) + .flex_1() + .min_w_0() + .truncate() + .text_color(moon(color)) + .tooltip(crate::panels::common::text_tooltip(tip)) + .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 cell and the range 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), + 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!( + "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), + ); + 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, 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, + odd: Option, + now: Option, + p: MoonPalette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + 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 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() + .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)) + .when(selected, |el| el.bg(moon_alpha(p.amber, 0.08))) + .child( + 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.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 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); + 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(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(), + }); + 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, + ) + .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, 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!("{VARIANT_INPUT_PREFIX}{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( + &mut self, + key: &'static str, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let id = format!("{VARIANT_INPUT_PREFIX}{key}"); + if let Some(state) = self.ticks.inputs.get(&id) { + return state.clone(); + } + 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, + 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.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(); + } + } + }, + ) + .detach(); + self.ticks.inputs.insert(id, state.clone()); + state + } +} + +/// 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. +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/lags.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs new file mode 100644 index 000000000..13f00fbc1 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/lags.rs @@ -0,0 +1,85 @@ +//! 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. +/// 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(); + 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, + }, + model, + ); + 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 new file mode 100644 index 000000000..c55d87a1a --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/load.rs @@ -0,0 +1,1022 @@ +//! Background loads of the "Entry/Exit" axis, in two stages. +//! +//! 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 +//! 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, +//! 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. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::sync::mpsc; +use std::time::Duration; + +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::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, 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; +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; +use moon_core::market::trade_replay::worker::inside_retention; +use moon_core::market::trade_replay::{ + 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 +/// 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: the deals, the grid's "now" values, the strategies' own values, the +/// 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, + OwnValues, + Arc, + Vec, + FieldDeps, + Arc>, +); + +/// 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 { + /// 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::TicksVariants, + ]); + 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 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("manual reload"); + } + 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()); + // 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. + // 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(); + 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( + &[ReadLane::Ticks], + show_overlay, + 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. + // `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, {} service, {} not tunable, period {}..{}, {} strategy target(s)", + read.deals.len(), + read.without_ms, + read.service, + read.untunable, + 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 own = deals + .as_ref() + .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 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, + &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, spans): StageA, cx| { + if this.ticks.seq != req { + return; + } + 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, + 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 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, + 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, + report_req, + after_report, + read, + ScopeView { + now, + own, + unmodelled, + grid, + spans, + integers, + }, + addresses, + cx, + ); + }, + ); + } + + /// 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 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 — 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. + #[allow(clippy::too_many_arguments)] + fn start_replay_stage( + &mut self, + req: u64, + report_req: u64, + after_report: bool, + read: DealsRead, + scope: ScopeView, + 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, + cx, + move || { + let rows: Vec = read + .deals + .into_iter() + .map(|deal| { + let address = addresses + .get(&(deal.core_uid, deal.coin.clone())) + .cloned() + .flatten(); + 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, + tape: if address.is_some() { + TapeStatus::Missing + } else { + TapeStatus::NoAddress + }, + verdict: None, + address, + 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(); + let mut data = TicksData { + rows, + without_ms: read.without_ms, + service: read.service, + untunable: read.untunable, + kpi: Vec::new(), + entry_share: (0, 0), + exit_share: (0, 0), + accuracy: Default::default(), + kinds, + now: scope.now, + own: scope.own, + unmodelled: scope.unmodelled, + grid: scope.grid, + spans: scope.spans, + integers: scope.integers, + }; + data.retain_within_cap(); + data.refresh_summary(); + (data, carried) + }, + move |this, (data, carried), 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); + // 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, carried.then_some(model), cx); + if after_report { + this.settle_report_refresh_retry(false, cx); + } + cx.notify(); + }, + ); + } + + /// 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("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; + 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. + /// + /// 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() { + 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); + // 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 + // BEFORE the answer over the answer. + let reading_since = std::time::Instant::now(); + self.spawn_latest_db( + &[ReadLane::TicksReplay], + false, + cx, + move || { + // 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 { + deal, + tape: TapeStatus::Missing, + verdict: None, + address: Some(address), + ticks: None, + entry_line: None, + held: None, + }) + .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, 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, 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 + // 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 + && let Some(address) = row.address.as_ref() + { + row.tape = unservable_status(address, &row.deal, now_ms) + .unwrap_or(TapeStatus::Missing); + } + } + 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. + 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, + model, + lines, + long_position_ms, + klines.as_ref(), + ); + } + } + rows + }, + move |this, rows, cx| { + 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); + 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 + // 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(); + }, + ); + } +} + +/// 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)], + long_position_ms: i64, +) -> HashMap { + let deadline = std::time::Instant::now() + HELD_ANSWER_WAIT; + let asked: Vec<( + i64, + mpsc::Receiver, + ReplayWindow, + )> = targets + .iter() + .filter_map(|(deal, address)| { + 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, window) 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, window)); + } + 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 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, + ReplayWindow, +)> { + let window = model_window(deal, margin_ms(), long_position_ms)?; + let (reply, rx) = mpsc::channel(); + query_held(TickQuery { + exchange_key: address.exchange_key.clone(), + market: address.market.clone(), + spans: window.focus_spans(), + reply, + }); + Some((rx, window)) +} + +/// The grid's "now" column: every selected strategy's current value per field, folded to +/// 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, 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), + None => Arc::new(strategy_current_values(sid, core, keys)), + }; + for key in keys { + seen.entry(key.clone()) + .or_default() + .push(values.get(key).cloned()); + } + read.push(((sid, core), values)); + } + 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()); + let value = if same { + NowValue::Same(first.unwrap_or_default()) + } else { + NowValue::Differs + }; + (key, value) + }) + .collect(); + (now, read) +} + +/// 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. +fn own_values(deals: &[Deal], keys: &[String]) -> 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`). +#[derive(Clone, Debug, Default)] +pub(super) struct ArchivedLines { + pub(super) entry_points: Option>, + pub(super) exit_points: Option>, + pub(super) answered: bool, +} + +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_points = lines + .iter() + .find(|l| l.own && l.kind == ArchivedLineKind::Entry) + .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_points, + exit_points, + answered: true, + } + } +} + +/// 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 + .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 { + out.insert(uid, ArchivedLines::of(&entry)); + } + } + out +} + +/// 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. +pub(super) fn held_tape( + address: &RowAddress, + deal: &Deal, + long_position_ms: i64, +) -> Option { + let (rx, window) = ask_held(address, deal, long_position_ms)?; + let answer = rx.recv_timeout(HELD_ANSWER_WAIT).ok()?; + 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 +/// 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 = model_window(deal, margin_ms(), long_position_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. `long_position_ms` is the row's own threshold — see +/// [`ask_held`]. +pub(super) fn replay_row( + row: &mut DealRow, + defaults: &HashMap, + model: ModelSettings, + 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, model, 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`, 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, + model: ModelSettings, + 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; + row.deal.bars = None; + let Some(address) = row.address.clone() else { + return; + }; + let Some((ticks, covered, window)) = tape else { + row.tape = TapeStatus::Missing; + 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. + if ticks.is_empty() || !covered.covers(&required_spans(&window)) { + row.tape = TapeStatus::Missing; + row.verdict = None; + return; + } + row.tape = TapeStatus::Covered; + // 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, + 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, model)) + } else { + EntryParams::Fact + }; + 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. 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, + address.btc_market.as_deref(), + &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. + prepare_deal( + &mut row.deal, + &entry, + &exit, + OwnLines { + entry: lines.entry_points.as_deref(), + 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); + row.verdict = Some(verify( + &row.deal, + &ticks, + &entry, + &exit, + lines.entry_points.as_deref(), + lines.exit_points.as_deref(), + )); + 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) { + 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: +/// 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 new file mode 100644 index 000000000..20f920c48 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/mod.rs @@ -0,0 +1,960 @@ +//! 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, 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 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. 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. +//! +//! 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, MoonCheckbox, MoonPalette, MoonScrollbarVisibility, + MoonTooltipView, MoonVirtualList, h_flex, v_flex, +}; +use rust_i18n::t; + +use super::super::AnalyticsView; +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::{DealRow, TapeStatus}; + +mod accuracy; +mod cfg; +pub(in crate::analytics::tuner) mod columns; +mod delta_summary; +mod estimate; +pub(crate) mod fetch; +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; +pub(in crate::analytics) mod tail; +mod tape; +mod trade_pane; +mod unmodelled; +mod variants; + +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, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let scale = design::font_scale(cx); + 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 + // sample the variants run on. + let drawn = rows::order_for(&mut self.ticks).len(); + 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". + 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(), + d.covered(), + d.fetchable().count(), + d.without_ms, + ) + }); + 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, + 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, + // 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_fit", hidden = total).to_string() + }, + 10.0, + p, + cx, + ), + total, + covered, + fetchable, + without_ms, + ), + 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)?)?; + 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()) + }) + .surface(false) + .border(false) + .radius(0.0) + .scrollbar_visibility(MoonScrollbarVisibility::Hover) + .into_any_element(); + (list, total, covered, fetchable, without_ms) + } + }; + // 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; + // 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); + } + // The button is only the switch — "fetch" or "stop"; what the batch is doing goes into + // 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. + 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 = markets.join(" · ") + ) + .to_string() + } 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 { + String::new() + }; + // 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, 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 + .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") + .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_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_fit = 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 { + t!("analytics.ticks.fetch_btn").to_string() + }; + let model_settings = self.ticks_model_settings(p, window, 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( + 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), + ) + // 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( + MoonButton::new("an-ticks-fetch") + .variant(if fetch_active { + MoonButtonVariant::Amber + } else { + 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); + } else { + this.ticks_fetch_missing(cx); + } + cx.notify(); + })) + .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. + .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)) + .children(deltas) + .child(only_switch), + ) + .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 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. + /// cx: View context. + fn open_deal_window(&mut self, report_uid: i64, cx: &mut Context) { + 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))?; + 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 + // `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, + }; + Some((target, q.axis)) + } + + /// 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 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) + } 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() + .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)), + column_title(c), + c.key, + Some(c), + ) + })) + } + + /// The right column of the axis: the matrix on top, the grid panel below it. + pub(in crate::analytics::tuner) fn ticks_side( + &mut self, + p: MoonPalette, + 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(kpi) + .child(grid) + .into_any_element() + } + + /// 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 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 + .data + .data() + .map(|d| { + ( + d.fit(), + d.covered(), + d.entry_share, + d.exit_share, + d.replayable().count(), + d.exit_horizon_ms(), + ) + }) + .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) + } + }; + // 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 = fit, + m = covered, + 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 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 stats: Vec = baseline.iter().cloned().collect(); + let title = t!("analytics.ticks.var_n", n = 1).to_string(); + let label = match &self.ticks.var_stats { + None => { + stats.extend(baseline.iter().cloned()); + 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 + .as_ref() + .and_then(|r| r.holdout.as_ref().map(|h| (h, r.holdout_open))) + { + sub = format!( + "{sub} · {}", + t!( + "analytics.ticks.holdout", + n = holdout.n, + 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)); + } + } + stats.push(var.clone()); + VarLabel::with_sub(title, sub) + } + }; + 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) => { + 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)) + } + crate::load_state::LoadState::Loading { stale } => { + crate::load_state::LoadState::Loading { + stale: stale.as_ref().map(|_| std::sync::Arc::new(stats)), + } + } + }; + kpi_matrix_card_over( + &state, + self.scope_label(), + &base, + &labels, + self.kpi_collapsed, + p, + cx, + ) + } +} + +/// 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(); + 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: 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>); + +impl PlanCell { + /// The deal's plan under the variant, from the state the column was scored into. + fn of(state: &state::TicksState, uid: i64) -> Self { + Self( + state + .var_stats + .as_ref() + .map(|_| state.plan.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 = || { + self.0.map(|v| { + format!( + "{} {} · {}", + t!("analytics.ticks.var_n", n = 1), + money(v), + t!("analytics.ticks.plan_tip") + ) + }) + }; + match self.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(), + ), + } + } +} + +/// 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) +} + +/// 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) + } +} + +/// 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(), + } +} + +/// 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()), + // 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!( + "analytics.ticks.tape_refused", + status = format!("{status:?}") + ) + .to_string(), + ), + } +} + +/// 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() + .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, + } +} + +/// 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 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, + untunable: usize, +) -> String { + let mut caption = t!( + "analytics.ticks.coverage", + covered = covered, + total = total, + fit = fit, + 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 { + 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. 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, + row_h: f32, + cx: &App, +) -> AnyElement { + let d = &row.deal; + let result = rows::result_pct(row); + let cell = |col: &DealCol, text: String, color: u32, tip: Option| { + 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!( + "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(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 => ( + 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 { + p.green + } else if result < 0.0 { + p.red + } else { + p.text_muted + }, + 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_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, + 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)); + } + 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| { + // The view may already be gone; a dropped window is not an error here. + 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/model_cfg.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/model_cfg.rs new file mode 100644 index 000000000..b733fbc83 --- /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 000000000..7791084ca --- /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/ranges.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/ranges.rs new file mode 100644 index 000000000..950a703a0 --- /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 000000000..f71aef276 --- /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 new file mode 100644 index 000000000..152bfd3e7 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows.rs @@ -0,0 +1,145 @@ +//! 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::*; +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)>, + 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 "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_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_fit || rows[i].fit()) + .collect(); + if let Some((key, desc)) = &state.sort { + sort_indices(rows, &state.plan, &mut order, key, *desc); + } + state.order = Some(OrderCache { + rows_rev: state.rows_rev, + sort: state.sort.clone(), + only_fit: state.only_fit, + 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], + 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])); + 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_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 } + }), + 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( + &|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 } + }), + 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 000000000..00c0f1041 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/rows/tests.rs @@ -0,0 +1,516 @@ +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}; +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, + core_name: String::new(), + 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, + profit: None, + deltas: Deltas::default(), + 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, + stop_anchor: None, + delta_track: None, + bars: None, + own_entry: None, + buy_set_ms: None, + corridor: None, + entry_placed: None, + gap: 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 mut rows = vec![ + DealRow { + deal: deal(1, 3_000, 100.0, 101.0, false), + tape: TapeStatus::Missing, + verdict: None, + address: None, + ticks: None, + entry_line: None, + held: 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_line: None, + held: 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_line: 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(); + // 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() + })); + 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 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_fit, + "off by default: the rows without tape are what the fetch button is for" + ); + state.only_fit = true; + assert_eq!(uids(&mut state), [2]); + // 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 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!(uids(&mut state).is_empty()); + // Flipping the switch alone rebuilds the order: the cache keys on it. + state.only_fit = false; + 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); +} + +/// 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(); + 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()); +} + +// ---- 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("SellPrice", " 0.5 ".into()); + state.set_variant("MShotPrice", "2".into()); + assert_eq!( + state.variant_changes(), + vec![ + ("MShotPrice".to_string(), "2".to_string()), + ("SellPrice".to_string(), "0.5".to_string()), + ] + ); + assert!(state.has_changes()); + state.set_variant("MShotPrice", " ".into()); + assert_eq!(state.variant_changes().len(), 1, "a blank clears the cell"); +} + +#[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, 0.8), None); + data.entry_share = (8, 10); + data.exit_share = (7, 10); + 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)); + // 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(); + 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(), + total: 3, + }; + 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, + seed: 1, + stats: Default::default(), + }); + state.invalidate(); + assert!(handle.is_cancelled()); + assert!(matches!(state.sugg, super::super::state::SuggState::Idle)); + assert!(state.var_stats.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" + ); +} + +/// 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); +} + +/// 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: Some((60_000, 60_000)), + }, + 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: Some((60_000, 60_000)), + }; + 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"); +} + +/// 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 new file mode 100644 index 000000000..c72488073 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections.rs @@ -0,0 +1,345 @@ +//! 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 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, 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` +//! 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::Deal; +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; +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, + /// 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. +#[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 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 +/// 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 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 — 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`]. 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 + .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 { + 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); + } + } + } + } + } + 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 — +/// 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, + }; + 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 +} + +/// 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() { + let Some(schema) = core.schema.as_ref() else { + continue; + }; + for kind in &schema.kinds { + for section in &kind.sections { + seen.extend(section.fields.iter().map(|f| f.name.as_str())); + } + } + } + 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. +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 000000000..b42db9095 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/sections/tests.rs @@ -0,0 +1,285 @@ +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") +} + +/// 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(&[], &[], &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())); +} + +#[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( + "Sell order\\SellSpread", + &["IgnoreSellSpread", "SellSpreadDistance"], + ), + section( + "Stops", + &[ + "UseStopLoss", + "StopLoss", + "UseTrailing", + "TrailingPercent", + "TrailingSpread", + ], + ), + section("Filters", &["MinVolume"]), + ]; + let out = layout(&[kind.as_slice()], &knobs, &all_used(&[kind.as_slice()])); + + 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); + + // 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!( + keys(stops)[..5], + [ + "UseStopLoss", + "StopLoss", + "UseTrailing", + "TrailingPercent", + "TrailingSpread" + ] + ); + // 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. + 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, &HashSet::new()); + 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, &HashSet::new()); + 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 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"]); + let mut seen = stops.clone(); + seen.sort(); + seen.dedup(); + assert_eq!(seen.len(), stops.len(), "{stops:?}"); +} + +#[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, &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 + // -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)); +} + +/// 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 new file mode 100644 index 000000000..b34d1dc78 --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/state.rs @@ -0,0 +1,889 @@ +//! 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::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; + +use gpui::Entity; +use moon_ui::MoonInputState; + +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}; +use moon_core::market::trade_replay::TickStatus; + +/// 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 +/// 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)] +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>, + /// 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>, + /// 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)>, +} + +impl DealRow { + /// 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) + && super::tail::holds(self.held) + } + + /// 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, 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 + /// 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.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; + 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 { + 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, + /// 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, +} + +/// 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, + /// Service rows with stamps the axis never takes (funding, liquidations, joined sells, no + /// 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. + pub(in crate::analytics::tuner) untunable: usize, + /// 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), + /// `(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. + 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, + /// 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]>, + /// 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. +#[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 { + self.rows + .iter() + .filter(|r| r.tape == TapeStatus::Covered) + .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 + .iter() + .filter(|r| r.tape == TapeStatus::Missing && r.address.is_some()) + } + + /// 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.fit() && r.ticks.is_some()) + } + + /// The share gate per group: whether the model reproduces at least `gate` (a fraction) of + /// 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, + gate: f64, + ) -> Option { + let (hits, n) = self.share_of(group); + (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 { + ParamGroup::Entry => self.entry_share, + ParamGroup::Exit => self.exit_share, + } + } + + /// 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() + && 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() + } +} + +/// 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, + /// 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("window closed"); + } +} + +/// State of the "Entry/Exit" mode. +pub(in crate::analytics) struct TicksState { + pub(in crate::analytics::tuner) data: LoadState, + /// 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, + /// 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>, + /// 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 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. + 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. + 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 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. + 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. + 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. + 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 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. + pub(in crate::analytics::tuner) rows_rev: u64, + /// 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, + /// 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. + 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, + /// 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`). + 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, + /// 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 { + fn default() -> Self { + Self { + data: LoadState::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(), + train_pct: super::super::filter::state::DEFAULT_TRAIN, + seed: String::new(), + last_seed: None, + passes: String::new(), + gate_pct: String::new(), + keep_corridor: true, + sugg_cfg_open: false, + model_cfg_open: false, + sugg: SuggState::Idle, + sugg_seq: 0, + last_result: None, + sugg_note: None, + kpi: LoadState::default(), + seq: 0, + dirty: true, + sort: Some((super::columns::COL_TIME.to_string(), true)), + only_fit: false, + order: None, + rows_rev: 0, + fetch_task: None, + fetch_listening: Default::default(), + tape_reading: false, + judged_under: None, + tape_seq: 0, + trade: Default::default(), + plan: HashMap::new(), + keys_sig: None, + schema_reload: None, + open_sections: HashSet::new(), + point_cost: None, + cost_pending: None, + cost_task: None, + } + } +} + +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(); + 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 + /// 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(), + 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(), + } + } + + /// 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 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; + self.var_seq = self.var_seq.wrapping_add(1); + self.var_task = None; + 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 { + 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 + } + + /// 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, 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; + 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) -> 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())) + .collect(); + out.sort(); + out + } + + /// Whether the variant holds anything to write. + pub(in crate::analytics::tuner) fn has_changes(&self) -> bool { + !self.variant_changes().is_empty() + } + + /// 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.variant.remove(key); + } else { + self.variant.insert(key.to_string(), value); + } + } + + /// 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: + /// edits: `(report_uid, what to do)`, in the order the job said it. + pub(in crate::analytics::tuner) fn edit_rows( + &mut self, + edits: impl IntoIterator, + ) { + 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(); + 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; + } + // 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. + 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; + } + + /// 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 — + // 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.take_replay(answer); + } + 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, + 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 { + /// 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(bytes) = row.ticks.as_ref().map(PackedTape::bytes) else { + continue; + }; + if !row.fit() || held + bytes > MAX_RETAINED_BYTES { + row.ticks = None; + } else { + 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 + /// 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), + ); + 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)); + 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/tail.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tail.rs new file mode 100644 index 000000000..b1f2e5dab --- /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 000000000..0cc09c950 --- /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/tape.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/tape.rs new file mode 100644 index 000000000..4c76f7b94 --- /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 000000000..d7b212f27 --- /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 new file mode 100644 index 000000000..a3eebdf4b --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/trade_pane.rs @@ -0,0 +1,293 @@ +//! 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, 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 +//! 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; +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::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 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 + /// 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 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, + ) { + 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 = self.ticks.variant_changes(); + 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.is_empty() { + return None; + } + Some(( + data.prepared(row)?, + data.exit_horizon_ms(), + data.single_kind().unwrap_or_default().to_string(), + )) + }); + let Some((pending, horizon_ms, 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 = pending.deal.is_short; + cx.spawn(async move |this, cx| { + let executor = cx.update(|cx| cx.background_executor().clone()); + 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. + let mut deal = pending.prepare(); + if let Some(horizon_ms) = horizon_ms { + clip_to_horizon(std::slice::from_mut(&mut deal), horizon_ms); + } + variant_picture(&deal, &defaults, &kind, &changes, model) + }) + .await; + let mut corridor: Vec = Vec::new(); + let trades: Vec = Some(picture) + .into_iter() + .filter_map(|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| { + !matches!(exit.kind, ExitKind::OpenAtWindowEnd | ExitKind::InGap) + }) + .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, + }) + }) + .collect(); + 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/unmodelled.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs new file mode 100644 index 000000000..0aa75eefa --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/unmodelled.rs @@ -0,0 +1,323 @@ +//! 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. + /// 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); + 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 000000000..2e8f6dee7 --- /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 outside = values(&[("UseBV_SV_Stop", "YES")]); + let plain = values(&[("SellPrice", "1.5"), ("IgnoreSellShot", "YES")]); + let map = unmodelled_map( + [((1, Some(7)), &outside), ((2, Some(7)), &plain)], + &HashMap::new(), + &FieldDeps::bundled(), + ); + 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 new file mode 100644 index 000000000..8556585ce --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants.rs @@ -0,0 +1,762 @@ +//! 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 +//! 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. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use gpui::*; +use rust_i18n::t; + +use super::super::super::AnalyticsView; +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::params::{self, ParamGroup}; +use moon_core::db::tuner::ticks::search::{ + DEFAULT_MAX_PASSES, SearchMiss, SearchParams, check_corridors, suggest, train_len, + variant_tally_by_deal, +}; +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); + +/// 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 { + text.trim() + .parse::() + .unwrap_or(DEFAULT_RESTARTS) + .clamp(1, 10_000) +} + +/// 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) +} + +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. + pub(super) fn prepared_deals(&self) -> Vec { + self.ticks + .data + .data() + .map(|d| d.replayable().filter_map(|row| d.prepared(row)).collect()) + .unwrap_or_default() + } + + /// 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 + && 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 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; + } + 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; + cx.update(|cx| { + let _ = this.update(cx, |this, cx| { + if this.ticks.var_seq == req { + this.run_ticks_variants(req, cx); + } + }); + }); + })); + } + + /// 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 values = self.ticks.variant_changes(); + let defaults = self.filter_defaults(cx); + let model = super::model_cfg::current(); + let n = pending.len(); + self.spawn_latest_db( + &[ReadLane::TicksVariants], + false, + cx, + move || { + let deals = prepare_sample(pending); + 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, scored, cx| { + if this.ticks.var_seq != req { + return; + } + 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); + cx.notify(); + }, + ); + } + + /// 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 + .sort + .as_ref() + .is_some_and(|(key, _)| key == super::columns::COL_PLAN) + { + self.ticks.order = None; + } + } + + /// One cell of the variant changed: store it and rescore. + pub(in crate::analytics::tuner) fn set_ticks_variant( + &mut self, + key: &str, + value: String, + cx: &mut Context, + ) { + self.ticks.set_variant(key, value); + self.arm_ticks_variants(cx); + } + + /// 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 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 ([`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 { + 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 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(); + 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| { + // 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| match k.group { + ParamGroup::Entry => entry_ok, + ParamGroup::Exit => exit_ok, + }) + .count() + }); + 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 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( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.ticks_run_search(None, window, cx); + } + + /// "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, + cx: &mut Context, + ) { + if let Some(key) = self.ticks.sel_field { + self.ticks_run_search(Some(key), window, cx); + } + } + + /// 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>, + window: &mut Window, + cx: &mut Context, + ) { + 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); + } + } + + /// 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(); + } + + /// 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. A searched + /// field starts from the strategies whatever В1 holds for it. + pub(super) fn ticks_start_search( + &mut self, + only: Option<&'static str>, + cx: &mut Context, + ) { + if matches!(self.ticks.sugg, SuggState::Running { .. }) { + return; + } + let pending = self.prepared_deals(); + let Some(data) = self.ticks.data.data() else { + return; + }; + let Some(kind) = data.single_kind().map(String::from) else { + 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. 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 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 pending.is_empty() { + return self.ticks_search_refused("analytics.ticks.sugg_no_tape", cx); + } + let defaults = self.filter_defaults(cx); + 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 + .ticks + .min_trades + .trim() + .parse::() + .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; + // 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 + // 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) { + 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(), + total: restarts, + }; + self.ticks.sugg_seq = self.ticks.sugg_seq.wrapping_add(1); + // 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}, {} 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); + 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, + cx, + move || { + let deals = prepare_sample(pending); + let params = SearchParams { + held: &held, + defaults: &defaults, + kind: &kind, + vary_entry, + vary_exit, + locked: &locked, + grids: &grids, + restarts, + min_n, + seed, + train_frac, + max_passes, + model, + keep_corridor, + }; + // 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, searched_for), 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; + } + this.ticks.sugg = SuggState::Idle; + match result { + Ok(result) => { + 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); + 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, no point that + // closed every trade it bought, 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::Unclosed => t!("analytics.ticks.sugg_unclosed").to_string(), + SearchMiss::Nothing => t!("analytics.ticks.sugg_none").to_string(), + }); + } + } + cx.notify(); + }, + ); + 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 { .. }); + // 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(); + } + }) + .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("Stop"); + 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(); + if changes.is_empty() { + log::info!("analytics: 'Save' (ticks) - no variant to write"); + 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)); + warns.extend(self.ticks_gate_warnings(Self::changed_groups(&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(); + 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); + } + + /// 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 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, _)| params::moves_entry(key)); + let Some(data) = self.ticks.data.data().filter(|_| moves_entry) else { + return Vec::new(); + }; + 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 mut warns = Vec::new(); + let check = check_corridors( + deals.iter().map(|(deal, own)| (*deal, own.as_ref())), + &defaults, + changes, + super::model_cfg::current(), + ); + if check.inverted > 0 { + warns.push( + t!( + "analytics.ticks.inverted_warn", + n = check.inverted, + m = check.checked + ) + .to_string(), + ); + } + if check.nearer > 0 { + warns.push( + t!( + "analytics.ticks.closer_warn", + n = check.nearer, + m = check.checked + ) + .to_string(), + ); + } + 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()] + } +} + +/// 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, searched: &[String], values: &[(String, String)]) { + for key in searched { + v1.remove(key); + } + 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(); + // 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, + n => format!("{line} · {}", t!("analytics.ticks.stats_left_open", n = n)), + } +} 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 000000000..25265bbb1 --- /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/src/analytics/tuner/ticks/variants/tests.rs b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs new file mode 100644 index 000000000..e92cd457e --- /dev/null +++ b/crates/moon-ui-gpui/src/analytics/tuner/ticks/variants/tests.rs @@ -0,0 +1,71 @@ +//! 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() +} + +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 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!( + v1, + cells(&[ + ("SellPrice", "1.5"), + ("StopLoss", "-3"), + ("UseTakeProfit", "YES"), + ("TakeProfit", "1"), + ]) + ); +} + +/// 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_searched_field_left_at_the_strategy_empties_its_cell() { + let mut v1 = cells(&[("SellPrice", "1.5")]); + land_answer(&mut v1, &keys(&["SellPrice"]), &[]); + assert!(v1.is_empty(), "{v1:?}"); +} + +/// "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_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/crates/moon-ui-gpui/src/design.rs b/crates/moon-ui-gpui/src/design.rs index 185644a66..b24e522e0 100644 --- a/crates/moon-ui-gpui/src/design.rs +++ b/crates/moon-ui-gpui/src/design.rs @@ -697,6 +697,48 @@ 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, + } +} + +/// 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/crates/moon-ui-gpui/src/design/tests.rs b/crates/moon-ui-gpui/src/design/tests.rs index 926d64d48..c29306b2f 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/load_state.rs b/crates/moon-ui-gpui/src/load_state.rs index cbb2b7f25..125d5a775 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) { @@ -502,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 599087144..c261907b2 100644 --- a/crates/moon-ui-gpui/src/settings/storage.rs +++ b/crates/moon-ui-gpui/src/settings/storage.rs @@ -267,6 +267,7 @@ impl SettingsView { 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 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; @@ -541,6 +542,23 @@ impl SettingsView { )), ) .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( + 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(); + } + })), + ) // 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( 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 9b6b37241..701be5408 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/startup/boot.rs b/crates/moon-ui-gpui/src/startup/boot.rs index 65bdf1131..4bf8930b3 100644 --- a/crates/moon-ui-gpui/src/startup/boot.rs +++ b/crates/moon-ui-gpui/src/startup/boot.rs @@ -709,9 +709,12 @@ 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. + // 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. // Nothing to walk while no ask is out. diff --git a/crates/moon-ui-gpui/src/strategies/mod.rs b/crates/moon-ui-gpui/src/strategies/mod.rs index 1a5ab403e..5a1f9a4b5 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/rules.rs b/crates/moon-ui-gpui/src/strategies/rules.rs index adec9b2a9..2cd8e469b 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/crates/moon-ui-gpui/src/strategies/sections.rs b/crates/moon-ui-gpui/src/strategies/sections.rs index 7905da48e..e6fbbf497 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 516df874c..f53f5f62b 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/crates/moon-ui-gpui/src/trade_window/mod.rs b/crates/moon-ui-gpui/src/trade_window/mod.rs index a27c2fd08..e17c7ed4c 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, } @@ -387,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 @@ -580,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 { @@ -606,10 +630,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, @@ -758,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/open_record.rs b/crates/moon-ui-gpui/src/trade_window/open_record.rs index d1cf71377..b4550f7f3 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), } diff --git a/crates/moon-ui-gpui/src/trade_window/settings.rs b/crates/moon-ui-gpui/src/trade_window/settings.rs index e005b5a20..21092c1d9 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 fe589ba6a..15dc54418 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, @@ -233,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. @@ -308,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); } @@ -348,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/crates/moon-ui-gpui/tests/theme_contract/analytics.rs b/crates/moon-ui-gpui/tests/theme_contract/analytics.rs index da68588d1..a9127ebd5 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/crates/moon-ui-gpui/tests/theme_contract/theme.rs b/crates/moon-ui-gpui/tests/theme_contract/theme.rs index 79ccca56f..8dccf4707 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/crates/moon-ui-gpui/tests/theme_contract/tuner.rs b/crates/moon-ui-gpui/tests/theme_contract/tuner.rs index 2543c1358..5f97c4edd 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/crates/moon-ui-gpui/tests/theme_contract/windowing.rs b/crates/moon-ui-gpui/tests/theme_contract/windowing.rs index b65203d5e..19e2b9a38 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/docs/PRODUCT_MAP.md b/docs/PRODUCT_MAP.md index 30e1a905b..0e8de1fe9 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. diff --git a/locales/analytics.yml b/locales/analytics.yml index 22e4cd3e5..410c909fd 100644 --- a/locales/analytics.yml +++ b/locales/analytics.yml @@ -1698,3 +1698,769 @@ 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} · годных %{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}" + es: "filas de servicio %{n}" +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.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 — штрих: путь бай- и селл-ордера модели" + 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" + 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" + 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)." + 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" + es: "Cargar trades" +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}…" + es: "trades: %{done}/%{total} · %{market}…" +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: "%" + es: "%" +analytics.ticks.col.profit: + ru: "прибыль" + en: "profit" + es: "beneficio" +analytics.ticks.col.duration: + ru: "длит." + en: "dur." + es: "dur." +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" + 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.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}" + es: "Entrada: modelo vs hecho %{entry} · Salida: %{exit}" +analytics.ticks.subset: + ru: "Годные" + en: "Reproduced" + es: "Reproducidas" +analytics.ticks.subset_sub: + 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" + es: " · salida ≤ %{h} tras el cierre" +analytics.ticks.params_title: + ru: "Параметры" + 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 (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" + 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.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_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" + 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.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" + 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" + 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" + 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} reproduced" + es: "%{n} de %{m} reproducidas" +analytics.ticks.holdout: + ru: "holdout: %{n} сделок, %{profit}" + 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} %: 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" + es: "La cuota de reproducción aún no se conoce — no hay operaciones con cinta" +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.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.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; 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" + 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}" + 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_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" + 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" + 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: "Коридор ближе к цене, чем был у %{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" + es: "sin cambios" +analytics.ticks.not_read: + ru: "не читается" + en: "not read" + es: "no se lee" +analytics.ticks.suggest_one_tip: + 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 и его ячейка не трогается, кроме значения, которое требует включённый подбором переключатель (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" + 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_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" + 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: "Warning share, %" + es: "Umbral de aviso, %" +analytics.ticks.cfg_gate_tip: + 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" + 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" + 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" diff --git a/locales/storage.yml b/locales/storage.yml index 387be4649..6458ee00a 100644 --- a/locales/storage.yml +++ b/locales/storage.yml @@ -129,25 +129,33 @@ 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" + es: "Descargar la cinta que falta al arrancar" +storage.trades_autoload_hint: + 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. From the next launch." - es: "Lo mismo que el botón «Limpiar». Desde el próximo arranque." + 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" diff --git a/locales/trade_window.yml b/locales/trade_window.yml index dd8897334..36ec78404 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"