From 75cc54ab71b30cc2e6b754a6fc1ec9db5ebf6b89 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:06:19 +0200 Subject: [PATCH] fix(strategies): stop false Adjusted on pasted strategies Every strategy pasted from a MoonBot export shows "core saved different values" although the core stored every parameter as sent. The create path sends all export keys as fields, including the service keys Active and FVersion, and looks field names up case-sensitively. The core drops names it does not know, so the echo lacks them and the edit resolves as Adjusted. Create and restore now send only fields the schema shows for the strategy's kind, under the schema's own spelling (BuyPrice -> buyPrice). Other keys are dropped and named once in the log. An Adjusted resolution now carries the fields that differ between the submitted snapshot and the echo. The log lists them as "name: sent -> saved", and the Strategies banner and the toast show the first three. --- crates/moon-core/src/feed/live/commands.rs | 2 + crates/moon-core/src/feed/live/mod.rs | 77 +++-- crates/moon-core/src/feed/strategies.rs | 309 +++++++++++++++++- crates/moon-core/src/feed/strategies/tests.rs | 268 +++++++++++++++ crates/moon-core/src/feed/types.rs | 29 +- crates/moon-core/src/session/store.rs | 7 +- crates/moon-ui-gpui/src/backend/mod.rs | 27 +- crates/moon-ui-gpui/src/shell/actions.rs | 9 +- .../moon-ui-gpui/src/strategies/adjusted.rs | 38 +++ .../src/strategies/adjusted/tests.rs | 27 ++ crates/moon-ui-gpui/src/strategies/mod.rs | 4 +- crates/moon-ui-gpui/src/strategies/params.rs | 18 +- .../strategies/tree/ops/moonbot_text/tests.rs | 36 ++ locales/shell.yml | 6 +- locales/strategies.yml | 6 +- 15 files changed, 812 insertions(+), 51 deletions(-) create mode 100644 crates/moon-ui-gpui/src/strategies/adjusted.rs create mode 100644 crates/moon-ui-gpui/src/strategies/adjusted/tests.rs diff --git a/crates/moon-core/src/feed/live/commands.rs b/crates/moon-core/src/feed/live/commands.rs index e6bc5917..9683d0e9 100644 --- a/crates/moon-core/src/feed/live/commands.rs +++ b/crates/moon-core/src/feed/live/commands.rs @@ -1049,6 +1049,7 @@ pub(super) fn drain_commands( next_id += 1; let fields = fields_from_text( schema, + StrategyKind::from_ordinal(spec.kind_ordinal), &spec.fields, server.id, &format!("create strategy {id}"), @@ -1091,6 +1092,7 @@ pub(super) fn drain_commands( } let f = fields_from_text( schema, + StrategyKind::from_ordinal(kind_ordinal), &fields, server.id, &format!("restore strategy {id}"), diff --git a/crates/moon-core/src/feed/live/mod.rs b/crates/moon-core/src/feed/live/mod.rs index 6c57940b..a97a85e1 100644 --- a/crates/moon-core/src/feed/live/mod.rs +++ b/crates/moon-core/src/feed/live/mod.rs @@ -46,8 +46,8 @@ use super::strategies::{ use super::{ ChartTextRows, ConnStatus, CoreCmd, CoreConfigEditEvent, CoreEndpoint, CoreLogLine, CoreStartupStatus, CoreTimeOffsetStatus, DetectRow, ExchangeId, FeedMsg, FeedTx, - LatestMarketRole, SharedMoonClient, StrategyEditPhase, StrategyEditResult, StrategyEditRow, - StrategyEditSnapshot, StrategyRow, + LatestMarketRole, SharedMoonClient, StrategyEditPhase, StrategyEditResolution, + StrategyEditResult, StrategyEditRow, StrategyEditSnapshot, StrategyRow, }; use crate::config::{ServerConfig, TransportVersion}; use crate::db::order_traces::{AskSink, TraceDbMsg}; @@ -274,28 +274,65 @@ fn strategy_edit_sig<'a>( /// moonproto removes the edit from its map in the same step that resolves it: by the time this /// runs, the live state has nothing left to read for the desired half. The log line exists to /// settle, on the first live run, whether the Delphi core renumbers `strategy_ver` when it -/// adjusts or supersedes a submitted edit. +/// adjusts or supersedes a submitted edit. An Adjusted line also names the fields that differ, +/// as `name: sent -> saved`, because the revision pair alone stays equal when the core rewrites +/// a value. fn record_strategy_edit_resolution( echo_snap: Option<&MoonStateSnapshot>, - desired_cache: &mut std::collections::HashMap, + desired_cache: &mut std::collections::HashMap, strategy_ids: &[u64], result: StrategyEditResult, core_id: u64, - notes: &mut Vec<(u64, StrategyEditResult)>, + notes: &mut Vec, ) { for &id in strategy_ids { let desired = desired_cache.remove(&id); - let echo = echo_snap - .and_then(|s| s.strats().snapshot(id)) - .map(|s| (s.strategy_ver, s.last_date)); - log::info!( - "core {} strategy {id} edit {result:?}: desired(ver,last_date)={desired:?} echo(ver,last_date)={echo:?}", - crate::feed::core_label(core_id) - ); - notes.push((id, result)); + let echo_rev = echo_snap + .and_then(|snap| snap.strats().snapshot(id)) + .map(|strategy| (strategy.strategy_ver, strategy.last_date)); + let desired_rev = desired + .as_ref() + .map(|strategy| (strategy.strategy_ver, strategy.last_date)); + let changes = if result == StrategyEditResult::Adjusted { + adjusted_field_changes(echo_snap, desired.as_ref(), id) + } else { + Vec::new() + }; + if result == StrategyEditResult::Adjusted { + log::info!( + "core {} strategy {id} edit Adjusted: desired(ver,last_date)={desired_rev:?} echo(ver,last_date)={echo_rev:?}{}", + crate::feed::core_label(core_id), + super::strategies::format_adjustment_log(&changes), + ); + } else { + log::info!( + "core {} strategy {id} edit {result:?}: desired(ver,last_date)={desired_rev:?} echo(ver,last_date)={echo_rev:?}", + crate::feed::core_label(core_id) + ); + } + notes.push(StrategyEditResolution { + id, + result, + changes, + }); } } +/// Field differences for one Adjusted id, or nothing when either snapshot is already gone. +fn adjusted_field_changes( + echo_snap: Option<&MoonStateSnapshot>, + desired: Option<&moonproto::StrategySnapshot>, + id: u64, +) -> Vec { + let (Some(desired), Some(snap)) = (desired, echo_snap) else { + return Vec::new(); + }; + let Some(echo) = snap.strats().snapshot(id).cloned() else { + return Vec::new(); + }; + super::strategies::strategy_field_changes(snap.strats().strategy_schema(), desired, &echo) +} + use account_reconciliation::{ AccountReconciliation, BALANCE_TRACE_LEVEL, balance_refresh_log_window, }; @@ -616,13 +653,14 @@ pub(super) fn run( // latch is set on ANY edit event and cleared only once a publish actually goes out, so a // resolution arriving inside the 250 ms shadow of the previous publish is delayed, never // dropped, by the rate limit. `strat_edit_desired_cache` remembers each pending edit's desired - // (ver, last_date) from the moment it was submitted, because moonproto removes the edit from - // its map in the same step that resolves it. + // snapshot from the moment it was submitted, because moonproto removes the edit from its map + // in the same step that resolves it. The ver/last_date pair used to be enough to log the + // revision; naming the fields an Adjusted echo changed needs the values too. let mut last_strat_edit_pub = Instant::now(); let mut last_strat_edit_sig: u64 = 0; let mut strat_edit_publish_pending = false; - let mut pending_strat_edit_notes: Vec<(u64, StrategyEditResult)> = Vec::new(); - let mut strat_edit_desired_cache: std::collections::HashMap = + let mut pending_strat_edit_notes: Vec = Vec::new(); + let mut strat_edit_desired_cache: std::collections::HashMap = std::collections::HashMap::new(); let mut last_strat_db_generation: Option<(u64, u64)> = None; let mut pending_strat_db_delivery: Option<((u64, u64), Receiver)> = None; @@ -1466,10 +1504,7 @@ pub(super) fn run( let strats = snap.strats(); for &id in strategy_ids { if let Some(edit) = strats.strategy_edit(id) { - strat_edit_desired_cache.insert( - id, - (edit.desired().strategy_ver, edit.desired().last_date), - ); + strat_edit_desired_cache.insert(id, edit.desired().clone()); } } } diff --git a/crates/moon-core/src/feed/strategies.rs b/crates/moon-core/src/feed/strategies.rs index cf85d4d2..77a8d7b9 100644 --- a/crates/moon-core/src/feed/strategies.rs +++ b/crates/moon-core/src/feed/strategies.rs @@ -2,11 +2,14 @@ //! field-value formatting/parsing, and kind names. use moonproto::{ - FieldValue, StrategyFieldType, StrategyFieldUiKind, StrategyFields, StrategySchema, - StrategySnapshot, + FieldValue, StrategyFieldType, StrategyFieldUiKind, StrategyFields, StrategyKind, + StrategySchema, StrategySnapshot, }; -use super::{SchemaField, SchemaFieldUi, SchemaKind, SchemaSection, StrategySchemaModel}; +use super::{ + STRATEGY_ADJUSTMENT_PREVIEW, SchemaField, SchemaFieldUi, SchemaKind, SchemaSection, + StrategyFieldChange, StrategySchemaModel, +}; /// Source-strategy parameters that affect the detect UI. /// When resolved by [`alert_params`], missing fields default to (false, 60): show the detect @@ -338,6 +341,57 @@ pub(super) fn fv_from_str( } } +/// One schema field the paste filter and the adjustment diff can hold after the schema borrow ends. +/// +/// `visible_ordinals` is the raw kind list `StrategySchemaField::visible_strategy_kinds` reports. +/// A paste accepts the field only when the strategy's kind is in that list. The diff uses the same +/// list the way moonproto's `field_matches` does: a value missing on one side equals the schema +/// default only when the field is visible for that side's kind. +struct KnownStrategyField { + name: String, + type_id: StrategyFieldType, + default_value: Option, + visible_ordinals: Vec, +} + +/// MoonBot export keys that are bookkeeping, not strategy parameters. +/// +/// `Active` is the checkbox and `FVersion` is the export format. Neither is a schema field, and +/// naming them on the info line would make every paste look like it dropped a parameter. +fn moonbot_service_key(key: &str) -> bool { + key.eq_ignore_ascii_case("Active") || key.eq_ignore_ascii_case("FVersion") +} + +/// Schema fields, in schema order, with the kinds each one is visible for. +fn known_fields(schema: &StrategySchema) -> Vec { + schema + .fields + .iter() + .map(|field| KnownStrategyField { + name: field.name.clone(), + type_id: field.type_id, + default_value: field.default_value.clone(), + visible_ordinals: field + .visible_strategy_kinds() + .map(|kind| kind.ordinal()) + .collect(), + }) + .collect() +} + +/// The schema field `key` names, preferring an exact spelling over an ASCII case-insensitive one. +/// +/// MoonBot's grid writes `BuyPrice` for a field the schema calls `buyPrice`. The lookup has to +/// find that field, and the caller then stores it under the schema's own spelling: the wire writer +/// and `field_matches` both compare names with `==`. +fn paste_slot<'a>(known: &'a [KnownStrategyField], key: &str) -> Option<&'a KnownStrategyField> { + known.iter().find(|field| field.name == key).or_else(|| { + known + .iter() + .find(|field| field.name.eq_ignore_ascii_case(key)) + }) +} + /// Convert `(name, text)` pairs into strategy fields, dropping any the core could not be sent. /// /// Shared by the create and restore paths, which differ only in what they call the strategy in the @@ -346,16 +400,42 @@ pub(super) fn fv_from_str( /// writer would have skipped anyway. Empty text is that very case rather than a defect — /// `ops::default_fields` spells "no schema default" as an empty string — so it passes silently, /// while text that means something and cannot be read does say so. +/// +/// When `schema` is present, a key is sent only if a field visible for `kind` answers to it, and +/// it is stored under that field's schema spelling. MoonBot service keys (`Active`, `FVersion`) +/// are dropped at debug; every other dropped key is named once, on one info line. Without a schema +/// there is nothing to match against, so the pairs pass through under the spelling they arrived +/// with — dropping them would send an empty strategy and the core would fill every field with its +/// default. +/// +/// Args: +/// schema: Live strategy schema, or `None` before the core has sent one. +/// kind: Kind of the strategy being created or restored. +/// pairs: Incoming `name=text` fields, in paste order. +/// server_id: Core the log line is about. +/// what: Short phrase naming the strategy, already including the verb (`create strategy 4`). +/// +/// Returns: +/// Fields safe to put on the snapshot. Never includes a name the schema does not show for +/// `kind` when `schema` is `Some`. pub(super) fn fields_from_text( schema: Option<&StrategySchema>, + kind: StrategyKind, pairs: &[(String, String)], server_id: u64, what: &str, ) -> StrategyFields { + match schema { + Some(schema) => fields_from_known(&known_fields(schema), kind, pairs, server_id, what), + None => fields_from_untyped(pairs, server_id, what), + } +} + +/// Pass pairs through when no schema can say which names exist. +fn fields_from_untyped(pairs: &[(String, String)], server_id: u64, what: &str) -> StrategyFields { let mut fields = StrategyFields::new(); for (name, val) in pairs { - let stype = schema.and_then(|s| s.field(name)).map(|f| f.type_id); - match fv_from_str(None, stype, val) { + match fv_from_str(None, None, val) { Some(value) => { fields.insert(name.as_str(), value); } @@ -369,6 +449,225 @@ pub(super) fn fields_from_text( fields } +/// Keep the pairs a visible schema field answers to, under that field's own spelling. +fn fields_from_known( + known: &[KnownStrategyField], + kind: StrategyKind, + pairs: &[(String, String)], + server_id: u64, + what: &str, +) -> StrategyFields { + let mut fields = StrategyFields::new(); + let mut taken: Vec = Vec::new(); + let mut dropped: Vec = Vec::new(); + let mut service: Vec = Vec::new(); + for (name, val) in pairs { + let Some(slot) = + paste_slot(known, name).filter(|slot| slot.visible_ordinals.contains(&kind.ordinal())) + else { + if moonbot_service_key(name) { + service.push(name.clone()); + } else { + dropped.push(name.clone()); + } + continue; + }; + if taken.iter().any(|seen| seen == &slot.name) { + dropped.push(name.clone()); + continue; + } + match fv_from_str(None, Some(slot.type_id), val) { + Some(value) => { + taken.push(slot.name.clone()); + fields.insert(slot.name.as_str(), value); + } + None if !val.trim().is_empty() => log::warn!( + "core {} {what}: field {name} omitted, {val:?} is not a value of its type", + super::core_label(server_id) + ), + None => {} + } + } + if !dropped.is_empty() { + log::info!( + "core {} {what}: dropped fields the schema does not show: {}", + super::core_label(server_id), + dropped.join(", ") + ); + } + if !service.is_empty() { + log::debug!( + "core {} {what}: dropped MoonBot service fields: {}", + super::core_label(server_id), + service.join(", ") + ); + } + fields +} + +/// Zero moonproto uses when a schema field has no explicit default. +/// +/// `field_matches` does `default_value.or_else(zero_for_type_id)`. The zero helper is crate-private +/// on moonproto, and the public `StrategyFieldType` is that same type id with the flag bits already +/// cleared, so this table is the copy the terminal can call. +fn zero_for_type(type_id: StrategyFieldType) -> Option { + Some(match type_id { + StrategyFieldType::Bool => FieldValue::Bool(false), + StrategyFieldType::Int32 => FieldValue::Int32(0), + StrategyFieldType::Int64 => FieldValue::Int64(0), + StrategyFieldType::UInt32 => FieldValue::UInt32(0), + StrategyFieldType::UInt64 => FieldValue::UInt64(0), + StrategyFieldType::Byte => FieldValue::Byte(0), + StrategyFieldType::Word => FieldValue::Word(0), + StrategyFieldType::Double => FieldValue::Double(0.0), + StrategyFieldType::Single => FieldValue::Single(0.0), + StrategyFieldType::String => FieldValue::String(String::new()), + StrategyFieldType::Unknown(_) => return None, + }) +} + +/// Value `side` effectively holds for `name`, treating a missing visible field as its default. +/// +/// This is moonproto `field_matches` from the side that is missing the key: a present value is +/// itself, and an absent one equals the schema default only when the field is visible for `kind`. +/// `None` means "no value", which does not match a present value — the same `false` `field_matches` +/// returns for an unknown name or a field hidden from that kind. +fn effective_field( + present: Option<&FieldValue>, + spec: Option<&KnownStrategyField>, + kind: u8, +) -> Option { + if let Some(value) = present { + return Some(value.clone()); + } + let spec = spec?; + if !spec.visible_ordinals.contains(&kind) { + return None; + } + spec.default_value + .clone() + .or_else(|| zero_for_type(spec.type_id)) +} + +fn show_field(value: Option<&FieldValue>) -> String { + match value { + Some(value) => fmt_field(value), + None => "absent".to_string(), + } +} + +/// Fields, checkbox, folder and kind that differ between the snapshot we sent and the core's echo. +/// +/// Compared with the same rules as moonproto `strategy_effectively_equal` / `field_matches`: a +/// missing field equals the schema default when the field is visible for that side's kind, and +/// `checked`, `path` and `kind` are compared on their own. Floats compare with `==`, as +/// `field_matches` does, not with the serializer's epsilon. +/// +/// Args: +/// schema: Schema that was current when the echo arrived. `None` treats every missing key as +/// absent rather than as a default, because there is no default to substitute. +/// desired: Snapshot this terminal submitted. +/// echo: Snapshot the core stored for the same id. +/// +/// Returns: +/// Differences in schema order, then any name only one side carries, then `checked`, `path` +/// and `kind` when those differ. Empty when the two snapshots agree. +pub(super) fn strategy_field_changes( + schema: Option<&StrategySchema>, + desired: &StrategySnapshot, + echo: &StrategySnapshot, +) -> Vec { + let known = schema.map(known_fields).unwrap_or_default(); + field_changes_against(&known, desired, echo) +} + +/// The comparison behind [`strategy_field_changes`], split out so a test can name defaults without +/// building a moonproto schema blob. +fn field_changes_against( + known: &[KnownStrategyField], + desired: &StrategySnapshot, + echo: &StrategySnapshot, +) -> Vec { + let mut names: Vec = Vec::new(); + for spec in known { + if desired.fields.get(&spec.name).is_some() || echo.fields.get(&spec.name).is_some() { + names.push(spec.name.clone()); + } + } + for (name, _) in desired.fields.iter().chain(echo.fields.iter()) { + if !names.iter().any(|seen| seen == name.as_ref()) { + names.push(name.to_string()); + } + } + let mut changes = Vec::new(); + for name in &names { + let spec = known.iter().find(|field| field.name == *name); + let sent = effective_field(desired.fields.get(name), spec, desired.kind().ordinal()); + let saved = effective_field(echo.fields.get(name), spec, echo.kind().ordinal()); + if sent == saved { + continue; + } + changes.push(StrategyFieldChange { + name: name.clone(), + sent: show_field(sent.as_ref()), + saved: show_field(saved.as_ref()), + }); + } + if desired.checked != echo.checked { + changes.push(StrategyFieldChange { + name: "checked".to_string(), + sent: yes_no(desired.checked).to_string(), + saved: yes_no(echo.checked).to_string(), + }); + } + if desired.path != echo.path { + changes.push(StrategyFieldChange { + name: "path".to_string(), + sent: desired.path.to_string(), + saved: echo.path.to_string(), + }); + } + if desired.kind() != echo.kind() { + changes.push(StrategyFieldChange { + name: "kind".to_string(), + sent: strat_kind_name(desired.kind().ordinal()).to_string(), + saved: strat_kind_name(echo.kind().ordinal()).to_string(), + }); + } + changes +} + +fn yes_no(value: bool) -> &'static str { + if value { "Yes" } else { "No" } +} + +/// The `name: sent -> saved` list appended to the Adjusted log line, bounded the same way the +/// banner is. +/// +/// Args: +/// changes: Differences from [`strategy_field_changes`], in report order. +/// +/// Returns: +/// Empty when nothing differed. Otherwise the first +/// [`STRATEGY_ADJUSTMENT_PREVIEW`] entries joined by `, `, plus ` +N` when more remain. +pub(super) fn format_adjustment_log(changes: &[StrategyFieldChange]) -> String { + if changes.is_empty() { + return String::new(); + } + let shown = changes.len().min(STRATEGY_ADJUSTMENT_PREVIEW); + let mut text = changes + .iter() + .take(shown) + .map(|change| format!("{}: {} -> {}", change.name, change.sent, change.saved)) + .collect::>() + .join(", "); + let rest = changes.len() - shown; + if rest > 0 { + text.push_str(&format!(" +{rest}")); + } + format!(" {text}") +} + /// Builds a decoupled model from moonproto `StrategySchema`: each kind contains its editor /// sections and their fields (name/type/widget kind/picklist/default). pub(super) fn build_schema_model(schema: &StrategySchema) -> StrategySchemaModel { diff --git a/crates/moon-core/src/feed/strategies/tests.rs b/crates/moon-core/src/feed/strategies/tests.rs index d7759c6f..5abd7efb 100644 --- a/crates/moon-core/src/feed/strategies/tests.rs +++ b/crates/moon-core/src/feed/strategies/tests.rs @@ -382,3 +382,271 @@ fn the_strategy_name_is_trimmed_before_the_length_cut() { "Foo Bar" ); } + +/// Trimmed from the user's MoonBot export. The service keys and the mixed-case `SellPrice` are +/// the ones the create path used to forward verbatim. +const PASTE_SAMPLE: &str = "\ +#Begin_Folder DIPBUY - LONG REBOUND AFTER DROPS LLM +##Begin_Strategy +Active=0 +FVersion=12 +StrategyName=DROPS_02 - LONG REBOUND AFTER DROPS [94HQE5E7] +LastEditDate=2026-09-25 12:00 +SignalType=DropsDetection +DropsMaxTime=90 +buyPrice=0.3 +SellPrice=2.2 +OrderSize=300 +MaxPing=600 +##End_Strategy +#End_Folder +"; + +/// Split one MoonBot strategy block into the pairs the create command receives. +/// +/// Not the product parser. That lives in the UI crate and is what turns `DropsDetection` into the +/// Drops kind. This only reads the `Key=Value` lines the builder is handed afterwards. +fn moonbot_field_pairs(text: &str) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + let mut inside = false; + for line in text.lines() { + let line = line.trim(); + if line == "##Begin_Strategy" { + inside = true; + continue; + } + if line == "##End_Strategy" { + break; + } + if !inside { + continue; + } + let (key, value) = line.split_once('=').expect("sample field"); + pairs.push((key.to_string(), value.to_string())); + } + pairs +} + +fn known( + name: &str, + type_id: StrategyFieldType, + default_value: Option, + visible_ordinals: &[u8], +) -> KnownStrategyField { + KnownStrategyField { + name: name.to_string(), + type_id, + default_value, + visible_ordinals: visible_ordinals.to_vec(), + } +} + +/// Sending `Active` and `FVersion` makes every MoonBot paste come back Adjusted: the core drops a +/// name its schema does not have, the echo lacks it, and `field_matches` returns false. Mapping +/// `SellPrice` onto `sellPrice` is the same failure one step earlier — the writer never finds the +/// mixed-case key, so the value the user typed is dropped too. +#[test] +fn pasted_sample_sends_only_schema_fields_in_schema_spelling() { + let pairs = moonbot_field_pairs(PASTE_SAMPLE); + assert!( + pairs + .iter() + .any(|(key, value)| key == "Active" && value == "0") + ); + assert!(pairs.iter().any(|(key, _)| key == "FVersion")); + assert!( + pairs + .iter() + .any(|(key, value)| key == "SignalType" && value == "DropsDetection") + ); + let drops = StrategyKind::DROPS.ordinal(); + let schema = vec![ + known("StrategyName", StrategyFieldType::String, None, &[drops]), + known("SignalType", StrategyFieldType::String, None, &[drops]), + known( + "buyPrice", + StrategyFieldType::Double, + Some(FieldValue::Double(0.0)), + &[drops], + ), + known( + "sellPrice", + StrategyFieldType::Double, + Some(FieldValue::Double(0.0)), + &[drops], + ), + known( + "orderSize", + StrategyFieldType::Int32, + Some(FieldValue::Int32(0)), + &[drops], + ), + known( + "DropsMaxTime", + StrategyFieldType::Int32, + Some(FieldValue::Int32(0)), + &[drops], + ), + // Present in the schema and in the paste, but hidden from this kind. + known("MaxPing", StrategyFieldType::Int32, None, &[99]), + ]; + let fields = fields_from_known(&schema, StrategyKind::DROPS, &pairs, 1, "create strategy 1"); + assert!( + fields.get("Active").is_none(), + "service key Active was sent" + ); + assert!( + fields.get("FVersion").is_none(), + "service key FVersion was sent" + ); + assert!( + fields.get("LastEditDate").is_none(), + "a key the schema does not know was sent" + ); + assert!( + fields.get("MaxPing").is_none(), + "a field hidden from this kind was sent" + ); + assert_eq!( + fields.get("SignalType"), + Some(&FieldValue::String("DropsDetection".into())) + ); + assert_eq!( + fields.get("StrategyName"), + Some(&FieldValue::String( + "DROPS_02 - LONG REBOUND AFTER DROPS [94HQE5E7]".into() + )) + ); + assert_eq!(fields.get("buyPrice"), Some(&FieldValue::Double(0.3))); + assert_eq!(fields.get("sellPrice"), Some(&FieldValue::Double(2.2))); + assert!(fields.get("SellPrice").is_none()); + assert_eq!(fields.get("orderSize"), Some(&FieldValue::Int32(300))); + assert!(fields.get("OrderSize").is_none()); + assert_eq!(fields.get("DropsMaxTime"), Some(&FieldValue::Int32(90))); + + let mixed = fields_from_known( + &schema, + StrategyKind::DROPS, + &[("BuyPrice".into(), "0.2".into())], + 1, + "create strategy 2", + ); + assert_eq!(mixed.get("buyPrice"), Some(&FieldValue::Double(0.2))); + assert!(mixed.get("BuyPrice").is_none()); + + // No schema yet: there is no list to filter against, and dropping every key would create a + // strategy of defaults. + let untyped = fields_from_text(None, StrategyKind::DROPS, &pairs, 1, "create strategy 3"); + assert!(untyped.get("Active").is_some()); +} + +fn snap( + checked: bool, + kind: StrategyKind, + path: &str, + fields: &[(&str, FieldValue)], +) -> StrategySnapshot { + let mut built = StrategyFields::new(); + for (name, value) in fields { + built.insert(*name, value.clone()); + } + StrategySnapshot::new(1, 12, 5, checked, kind, path, built) +} + +/// A paste that comes back Adjusted used to say only that the core saved something else. The note +/// has to name the one field that actually differs, and it must not name a field the echo omitted +/// because the value was already the schema default — that omission is how the core stores a +/// default, and reporting it would flag every untouched field. +#[test] +fn an_adjustment_names_the_field_that_differs_and_ignores_a_default() { + let drops = StrategyKind::DROPS.ordinal(); + let known = vec![ + known( + "buyPrice", + StrategyFieldType::Double, + Some(FieldValue::Double(0.0)), + &[drops], + ), + known( + "stopLoss", + StrategyFieldType::Double, + Some(FieldValue::Double(-1.0)), + &[drops], + ), + known( + "keepAlert", + StrategyFieldType::Int32, + Some(FieldValue::Int32(60)), + &[drops], + ), + known("orderSize", StrategyFieldType::Int32, None, &[drops]), + ]; + let desired = snap( + false, + StrategyKind::DROPS, + "folder", + &[ + ("buyPrice", FieldValue::Double(0.2)), + ("stopLoss", FieldValue::Double(-1.0)), + ("orderSize", FieldValue::Int32(0)), + ], + ); + let echo = snap( + false, + StrategyKind::DROPS, + "folder", + &[ + ("buyPrice", FieldValue::Double(0.3)), + ("keepAlert", FieldValue::Int32(60)), + ], + ); + let changes = field_changes_against(&known, &desired, &echo); + assert_eq!( + changes, + vec![StrategyFieldChange { + name: "buyPrice".to_string(), + sent: "0.2".to_string(), + saved: "0.3".to_string(), + }] + ); + + let bumped = snap( + false, + StrategyKind::DROPS, + "folder", + &[("orderSize", FieldValue::Int32(5))], + ); + let omitted = snap(false, StrategyKind::DROPS, "folder", &[]); + let implicit = field_changes_against(&known, &bumped, &omitted); + assert_eq!( + implicit, + vec![StrategyFieldChange { + name: "orderSize".to_string(), + sent: "5".to_string(), + saved: "0".to_string(), + }] + ); + + let checked = snap(true, StrategyKind::DROPS, "folder", &[]); + assert_eq!( + field_changes_against(&[], &checked, &omitted) + .iter() + .map(|change| change.name.as_str()) + .collect::>(), + vec!["checked"] + ); + let moved = snap(false, StrategyKind::DROPS, "other", &[]); + assert_eq!( + field_changes_against(&[], &omitted, &moved) + .iter() + .map(|change| change.name.as_str()) + .collect::>(), + vec!["path"] + ); + let waves = snap(false, StrategyKind::WAVES, "folder", &[]); + let kind_change = field_changes_against(&[], &omitted, &waves); + assert_eq!(kind_change.len(), 1); + assert_eq!(kind_change[0].name, "kind"); + assert_eq!(kind_change[0].sent, "Drops"); + assert_eq!(kind_change[0].saved, "Waves"); +} diff --git a/crates/moon-core/src/feed/types.rs b/crates/moon-core/src/feed/types.rs index d6c80bde..2f17d63f 100644 --- a/crates/moon-core/src/feed/types.rs +++ b/crates/moon-core/src/feed/types.rs @@ -523,6 +523,30 @@ pub struct StrategyEditRow { pub fields: Vec<(String, String)>, } +/// How many adjusted fields a log line or a banner names before it ends with `+N`. +pub const STRATEGY_ADJUSTMENT_PREVIEW: usize = 3; + +/// One place the core kept a different value from the snapshot this terminal sent. +/// +/// `name` is the schema field, or `checked` / `path` / `kind` when that part of the snapshot +/// differs. `sent` and `saved` are already formatted for display; this crate does not localize, +/// so the UI wraps them with `t!`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StrategyFieldChange { + pub name: String, + pub sent: String, + pub saved: String, +} + +/// One resolved strategy edit plus, for [`StrategyEditResult::Adjusted`], the fields that differ. +#[derive(Debug, Clone, PartialEq)] +pub struct StrategyEditResolution { + pub id: u64, + pub result: StrategyEditResult, + /// Empty unless `result` is [`StrategyEditResult::Adjusted`]. + pub changes: Vec, +} + /// One resolved strategy edit: the core's final verdict on a submission this terminal made. #[derive(Debug, Clone, PartialEq)] pub struct StrategyEditNote { @@ -532,6 +556,9 @@ pub struct StrategyEditNote { pub id: u64, pub result: StrategyEditResult, pub at_ms: i64, + /// Empty unless `result` is [`StrategyEditResult::Adjusted`]. The UI formats a bounded prefix + /// of this list; the full list stays here so `+N` counts what was actually different. + pub changes: Vec, } /// Strategy-edit state published on its own cadence, faster than the heavy [`StrategyRow`] @@ -545,7 +572,7 @@ pub struct StrategyEditNote { pub struct StrategyEditSnapshot { /// Absence means resolved: a strategy id with no row here has no open edit. pub open: Vec, - pub resolved: Vec<(u64, StrategyEditResult)>, + pub resolved: Vec, } /// Cap on resolved strategy-edit notes retained per core. diff --git a/crates/moon-core/src/session/store.rs b/crates/moon-core/src/session/store.rs index 7391e22b..7580ffb7 100644 --- a/crates/moon-core/src/session/store.rs +++ b/crates/moon-core/src/session/store.rs @@ -989,13 +989,14 @@ impl CoreData { self.strategy_edit_rev = self.strategy_edit_rev.wrapping_add(1); let at_ms = now_unix_ms_i64(); let mut notes_pushed_this_apply = 0usize; - for (id, result) in snapshot.resolved { + for resolution in snapshot.resolved { self.strategy_edit_note_seq = self.strategy_edit_note_seq.wrapping_add(1); self.strategy_edit_notes.push_back(StrategyEditNote { seq: self.strategy_edit_note_seq, - id, - result, + id: resolution.id, + result: resolution.result, at_ms, + changes: resolution.changes, }); notes_pushed_this_apply += 1; } diff --git a/crates/moon-ui-gpui/src/backend/mod.rs b/crates/moon-ui-gpui/src/backend/mod.rs index a423cc0c..168ef669 100644 --- a/crates/moon-ui-gpui/src/backend/mod.rs +++ b/crates/moon-ui-gpui/src/backend/mod.rs @@ -40,7 +40,7 @@ use crate::chartdx::ChartDataHandle; use crate::core_order::{CoreOrder, OrderedCores}; use moon_core::config::{CoreGroup, WorkspaceMode}; use moon_core::db::valuation::ValuationMode; -use moon_core::feed::{StrategyEditOutcome, StrategyEditResult}; +use moon_core::feed::{StrategyEditOutcome, StrategyEditResult, StrategyFieldChange}; use moon_core::market::MarketLimits; use moon_core::session::CoreId; use moon_ui::{DockAreaState, DockTopologyByName}; @@ -3012,9 +3012,11 @@ impl Backend { // `store()` is re-fetched per core rather than hoisted: different cores need // different `CoreData`, and the immutable borrow must end before the cursor map (a // different field) is written below. + let mut notes = Vec::new(); if let Some(core_data) = self.session.store().core(core) { for note in core_data.strategy_edit_notes_since(cursor) { batch_max = batch_max.max(note.seq); + notes.push(note.clone()); } } self.strategy_edit_note_cursor.insert(core, batch_max); @@ -3029,8 +3031,14 @@ impl Backend { match outcome { StrategyEditOutcome::Resolved(StrategyEditResult::Confirmed) => continue, StrategyEditOutcome::Resolved(StrategyEditResult::Adjusted) => { + let changes = notes + .iter() + .find(|note| note.id == watch.id) + .map(|note| note.changes.clone()) + .unwrap_or_default(); events.push(StrategyEditToast::Adjusted { coin: watch.coin.clone(), + changes, }); continue; // resolved; drop the watch } @@ -3078,8 +3086,17 @@ pub(crate) struct PendingStrategyEditWatch { /// `MoonNotification` or touches a window -- that stays in `Shell::drain_strategy_edit_toasts`, /// the only place with window access. pub(crate) enum StrategyEditToast { - Sent { coin: String }, - Adjusted { coin: String }, - Superseded { coin: String }, - TimedOut { coin: String }, + Sent { + coin: String, + }, + Adjusted { + coin: String, + changes: Vec, + }, + Superseded { + coin: String, + }, + TimedOut { + coin: String, + }, } diff --git a/crates/moon-ui-gpui/src/shell/actions.rs b/crates/moon-ui-gpui/src/shell/actions.rs index aa8a2282..16026e16 100644 --- a/crates/moon-ui-gpui/src/shell/actions.rs +++ b/crates/moon-ui-gpui/src/shell/actions.rs @@ -472,8 +472,13 @@ fn strategy_edit_toast_notification( T::Sent { coin } => { moon_ui::MoonNotification::info(t!("shell.strat_edit_sent", coin = coin).to_string()) } - T::Adjusted { coin } => moon_ui::MoonNotification::warning( - t!("shell.strat_edit_adjusted", coin = coin).to_string(), + T::Adjusted { coin, changes } => moon_ui::MoonNotification::warning( + t!( + "shell.strat_edit_adjusted", + coin = coin, + diffs = crate::strategies::adjusted_diff_suffix(&changes) + ) + .to_string(), ) .autohide(false), T::Superseded { coin } => moon_ui::MoonNotification::warning( diff --git a/crates/moon-ui-gpui/src/strategies/adjusted.rs b/crates/moon-ui-gpui/src/strategies/adjusted.rs new file mode 100644 index 00000000..cf34c9a7 --- /dev/null +++ b/crates/moon-ui-gpui/src/strategies/adjusted.rs @@ -0,0 +1,38 @@ +//! Bounded text for an Adjusted strategy edit. +//! +//! moon-core passes the field names and the formatted values. This module wraps them in the +//! sentence the locale owns, and stops the sentence after a few fields so a strategy that differs +//! in dozens of places does not paint a paragraph into the banner. + +use moon_core::feed::{STRATEGY_ADJUSTMENT_PREVIEW, StrategyFieldChange}; + +#[cfg(test)] +mod tests; + +/// Suffix for `strat.edit_adjusted` / `shell.strat_edit_adjusted`. +/// +/// Args: +/// changes: Differences the core reported, in report order. Empty when the Adjusted note +/// arrived without a field list. +/// +/// Returns: +/// `""` when `changes` is empty, so the locale sentence stays the short form. Otherwise +/// `": name (sent → saved), …"` for the first [`STRATEGY_ADJUSTMENT_PREVIEW`] fields, then +/// ` +N` for however many remain. +pub(crate) fn adjusted_diff_suffix(changes: &[StrategyFieldChange]) -> String { + if changes.is_empty() { + return String::new(); + } + let shown = changes.len().min(STRATEGY_ADJUSTMENT_PREVIEW); + let mut text = changes + .iter() + .take(shown) + .map(|change| format!("{} ({} → {})", change.name, change.sent, change.saved)) + .collect::>() + .join(", "); + let rest = changes.len() - shown; + if rest > 0 { + text.push_str(&format!(" +{rest}")); + } + format!(": {text}") +} diff --git a/crates/moon-ui-gpui/src/strategies/adjusted/tests.rs b/crates/moon-ui-gpui/src/strategies/adjusted/tests.rs new file mode 100644 index 00000000..c2d78510 --- /dev/null +++ b/crates/moon-ui-gpui/src/strategies/adjusted/tests.rs @@ -0,0 +1,27 @@ +use super::*; +use moon_core::feed::StrategyFieldChange; + +fn change(name: &str) -> StrategyFieldChange { + StrategyFieldChange { + name: name.to_string(), + sent: "0".to_string(), + saved: "1".to_string(), + } +} + +/// Dropping the bound, or printing every field, turns the banner into a paragraph the moment a +/// paste disagrees in more than a handful of values. The user then cannot see which value the +/// core actually kept. +#[test] +fn the_suffix_names_the_first_fields_and_counts_the_rest() { + assert_eq!(adjusted_diff_suffix(&[]), ""); + assert_eq!( + adjusted_diff_suffix(&[change("buyPrice")]), + ": buyPrice (0 → 1)" + ); + let many: Vec<_> = (0..5).map(|i| change(&format!("f{i}"))).collect(); + assert_eq!( + adjusted_diff_suffix(&many), + ": f0 (0 → 1), f1 (0 → 1), f2 (0 → 1) +2" + ); +} diff --git a/crates/moon-ui-gpui/src/strategies/mod.rs b/crates/moon-ui-gpui/src/strategies/mod.rs index 5f098e2e..1a5ab403 100644 --- a/crates/moon-ui-gpui/src/strategies/mod.rs +++ b/crates/moon-ui-gpui/src/strategies/mod.rs @@ -10,6 +10,7 @@ //! through `session.apply_strategies`. mod actions; +mod adjusted; mod fields; mod filter; mod full_params; @@ -59,7 +60,7 @@ use crate::design::{moon, moon_alpha}; use crate::{Backend, design}; use moon_core::feed::{ SchemaField, SchemaFieldUi, SchemaSection, StrategyEditNote, StrategyEditPhase, - StrategyEditResult, StrategyEditRow, StrategyRow, + StrategyEditResult, StrategyEditRow, StrategyFieldChange, StrategyRow, }; use moon_core::session::{CoreId, CoreStore}; use rust_i18n::t; @@ -69,6 +70,7 @@ use logic::*; use rules::{Rules, Values}; use settings::StrategiesPrefs; +pub(crate) use adjusted::adjusted_diff_suffix; pub(crate) use session::StrategiesSessionState; pub type Key = (CoreId, u64); diff --git a/crates/moon-ui-gpui/src/strategies/params.rs b/crates/moon-ui-gpui/src/strategies/params.rs index 65f8c2a3..86075dc7 100644 --- a/crates/moon-ui-gpui/src/strategies/params.rs +++ b/crates/moon-ui-gpui/src/strategies/params.rs @@ -1684,7 +1684,8 @@ impl StrategiesView { edit_notes: &[(CoreId, StrategyEditNote)], cx: &mut Context, ) -> Option { - let mut note_banner: Option<(StrategyEditResult, CoreId, u64)> = None; + let mut note_banner: Option<(StrategyEditResult, CoreId, u64, Vec)> = + None; for (core, id) in row_pairs.iter().map(|(key, _)| *key) { let Some(note) = edit_notes .iter() @@ -1703,17 +1704,20 @@ impl StrategiesView { Some(_) => note.result == StrategyEditResult::Superseded, }; if outranks { - note_banner = Some((note.result, core, note.seq)); + note_banner = Some((note.result, core, note.seq, note.changes.clone())); } } - if let Some((result, core, seq)) = note_banner { - let key = match result { - StrategyEditResult::Adjusted => "strat.edit_adjusted", - StrategyEditResult::Superseded => "strat.edit_superseded", + if let Some((result, core, seq, changes)) = note_banner { + let message = match result { + StrategyEditResult::Adjusted => t!( + "strat.edit_adjusted", + diffs = super::adjusted_diff_suffix(&changes) + ) + .to_string(), + StrategyEditResult::Superseded => t!("strat.edit_superseded").to_string(), StrategyEditResult::Confirmed => unreachable!("filtered above"), }; - let message = t!(key).to_string(); return Some( h_flex() .w_full() diff --git a/crates/moon-ui-gpui/src/strategies/tree/ops/moonbot_text/tests.rs b/crates/moon-ui-gpui/src/strategies/tree/ops/moonbot_text/tests.rs index f05a13f3..ff1d3745 100644 --- a/crates/moon-ui-gpui/src/strategies/tree/ops/moonbot_text/tests.rs +++ b/crates/moon-ui-gpui/src/strategies/tree/ops/moonbot_text/tests.rs @@ -279,3 +279,39 @@ fn magnitude_expansion_is_exact_and_refuses_non_numbers() { assert_eq!(expand_magnitude(raw), None, "{raw}"); } } + +/// The user's export: `SignalType=DropsDetection` is MoonBot's name for Drops, and `StrategyName` +/// is the row title. Stripping either here would create the strategy under the wrong kind or with +/// no name. Service keys stay in the clip; the create path drops them once it knows the schema. +const USER_EXPORT: &str = "\ +#Begin_Folder DIPBUY - LONG REBOUND AFTER DROPS LLM +##Begin_Strategy +Active=0 +FVersion=12 +StrategyName=DROPS_02 - LONG REBOUND AFTER DROPS [94HQE5E7] +LastEditDate=2026-09-25 12:00 +SignalType=DropsDetection +buyPrice=0.3 +SellPrice=2.2 +##End_Strategy +#End_Folder +"; + +#[test] +fn the_user_export_keeps_drops_detection_and_the_strategy_name() { + let clip = parse(USER_EXPORT, &kinds()).expect("sample export"); + assert_eq!(clip.len(), 1); + assert_eq!(clip[0].kind_ordinal, 2); + assert_eq!(clip[0].kind, "Drops"); + assert_eq!( + clip[0].name, + "DROPS_02 - LONG REBOUND AFTER DROPS [94HQE5E7]" + ); + assert!( + clip[0] + .fields + .contains(&("SignalType".into(), "DropsDetection".into())) + ); + assert!(clip[0].fields.contains(&("Active".into(), "0".into()))); + assert!(clip[0].fields.iter().any(|(key, _)| key == "FVersion")); +} diff --git a/locales/shell.yml b/locales/shell.yml index d3d7bfe5..b2088e73 100644 --- a/locales/shell.yml +++ b/locales/shell.yml @@ -1573,9 +1573,9 @@ shell.strat_edit_sent: en: "Blacklist edit for %{coin} sent" es: "Edición de lista negra para %{coin} enviada" shell.strat_edit_adjusted: - ru: "%{coin}: ядро сохранило другое значение" - en: "%{coin}: core saved a different value" - es: "%{coin}: el núcleo guardó un valor distinto" + ru: "%{coin}: ядро сохранило другое значение%{diffs}" + en: "%{coin}: core saved a different value%{diffs}" + es: "%{coin}: el núcleo guardó un valor distinto%{diffs}" shell.strat_edit_superseded: ru: "%{coin}: правку опередила другая" en: "%{coin}: another edit changed it first" diff --git a/locales/strategies.yml b/locales/strategies.yml index 0be53a21..08a8f4e6 100644 --- a/locales/strategies.yml +++ b/locales/strategies.yml @@ -258,9 +258,9 @@ strat.edit_timeout: en: "not confirmed by the core" es: "sin confirmar por el núcleo" strat.edit_adjusted: - ru: "ядро сохранило другие значения" - en: "the core saved different values" - es: "el núcleo guardó valores distintos" + ru: "ядро сохранило другие значения%{diffs}" + en: "the core saved different values%{diffs}" + es: "el núcleo guardó valores distintos%{diffs}" strat.edit_superseded: ru: "это изменение перекрыто другим, отправленным раньше" en: "another change sent first overwrote this one"