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"