Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions crates/moon-chart/src/trade_marks/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,22 @@ fn trade_hit_area_grows_with_the_drawn_arrow_scale() {
vec![0]
);
}

/// The shipped graphics settings must survive their own normalizer.
///
/// `normalize_chart_graphics` exists because this value is COMPARED — a chart re-bakes its base
/// texture when its settings differ from the ones it drew with — so a value that the normalizer
/// still moves differs from itself on every notification, which is a re-bake per frame rather than
/// a wrong pixel. The defaults are handed straight to a chart by
/// `WindowLayout::reset_chart_graphics_default`, without passing the normalizer on the way, and
/// this is what keeps that shortcut honest: a `def_*` that ever drifts outside its own clamp fails
/// here rather than in the frame loop.
#[test]
fn the_shipped_graphics_survive_their_own_normalizer() {
let shipped = ChartGraphicsCfg::default();
assert_eq!(
normalize_chart_graphics(shipped),
shipped,
"a shipped graphics default sits outside the range its own normalizer accepts"
);
}
52 changes: 43 additions & 9 deletions crates/moon-core/src/config/chart_defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
//! on `WindowLayout` stays the [`ChartTabKind::Main`] default — an old profile keeps working, and a
//! reader who never touches the feature keeps one default for everything — and the other kinds hold
//! [`ChartTabDefaults`], which is empty until the first time a default is set FOR that kind. Empty
//! means "follow Main", except for the trade window's captions, which fall back to their own
//! built-in set ([`crate::config::ChartLabelsCfg::trade_default`]); see
//! means "follow Main", except for the CAPTIONS of the two kinds that ship their own set — the
//! trade window ([`crate::config::ChartLabelsCfg::trade_default`]) and a comparison
//! ([`crate::config::ChartLabelsCfg::compare_default`]); see
//! [`WindowLayout::set_chart_labels_default`](super::layout::WindowLayout::set_chart_labels_default)
//! for what the first press does about that.

Expand Down Expand Up @@ -43,13 +44,17 @@ pub enum ChartTabKind {
AddTo,
/// A tab under the anchor lock, wherever it lives: in the strip, in a window, or the main
/// chart itself.
///
/// Ships its own captions ([`crate::config::ChartLabelsCfg::compare_default`]): several panes
/// of one coin, each a third of the usual width, are read by what tells them APART, and the
/// live default's per-market blocks are printed over and over on such a tab.
Compare,
/// The trade-detail window: one closed trade, drawn from a frozen replay.
///
/// Not a tab at all, and the only kind that is a WINDOW rather than a state a tab can be in —
/// which is exactly why it needs its own defaults. It shows a market that stopped moving hours
/// ago, so the captions a live chart is read with describe something that is not on the screen.
/// It is the one kind whose caption default is NOT Main's: see
/// Its caption default is its own rather than Main's, as [`Self::Compare`]'s is: see
/// [`crate::config::ChartLabelsCfg::trade_default`].
Trade,
}
Expand All @@ -63,6 +68,24 @@ impl ChartTabKind {
ChartTabKind::Trade,
];

/// Every kind, in the order a RESET must visit them: Main first.
///
/// A reset of Main goes through the setter, whose separation pass freezes every kind that still
/// FOLLOWS Main — which is what stops a press on the main chart from moving a kind the reader
/// never ticked. A kind the same press also addresses is emptied again on its own turn, so it
/// ends up following the reset Main rather than frozen at the value just discarded — and that
/// only holds while Main is visited first.
///
/// Beside [`Self::ALL`] rather than as a rule written at the caller: the ordering is a property
/// of what these kinds ARE to each other, and the caller is in another crate where nothing
/// tests it.
pub const RESET_ORDER: [ChartTabKind; 4] = [
ChartTabKind::Main,
ChartTabKind::AddTo,
ChartTabKind::Compare,
ChartTabKind::Trade,
];

/// The kinds that are TABS, for the walks that visit tabs.
///
/// [`Self::Trade`] is absent: its windows are opened from the Report and live outside the tab
Expand Down Expand Up @@ -93,7 +116,14 @@ impl ChartTabKind {
static TRADE: std::sync::OnceLock<ChartLabelsCfg> = std::sync::OnceLock::new();
Some(TRADE.get_or_init(ChartLabelsCfg::trade_default))
}
ChartTabKind::Main | ChartTabKind::AddTo | ChartTabKind::Compare => None,
// A comparison draws the SAME market several times over, in panes a third the width, so
// Main's per-market blocks are printed once per pane and the pane's own identity — its
// venue, its scale, its spread against the anchor — is what the eye is there for.
ChartTabKind::Compare => {
static COMPARE: std::sync::OnceLock<ChartLabelsCfg> = std::sync::OnceLock::new();
Some(COMPARE.get_or_init(ChartLabelsCfg::compare_default))
}
ChartTabKind::Main | ChartTabKind::AddTo => None,
}
}

Expand Down Expand Up @@ -126,11 +156,15 @@ impl ChartTabKind {
/// a reader who never splits the defaults apart. It is per SETTING rather than per kind: setting
/// the caption default for windows must not freeze their candles as a side effect.
///
/// The FIRST press for a setting fills this in for EVERY other non-Main kind, not only the one
/// addressed: separating the defaults is the moment they stop moving together, and a Compare that
/// still followed Main would jump the next time the main chart's default was set — which is the
/// surprise the split exists to remove. Each kind is frozen at what IT was showing, which for the
/// trade window's captions is its own built-in set rather than Main's.
/// The FIRST press for a setting fills this in for every other non-Main kind that FOLLOWS Main, not
/// only the one addressed: separating the defaults is the moment they stop moving together, and an
/// AddTo that still followed Main would jump the next time the main chart's default was set — which
/// is the surprise the split exists to remove. Each such kind is frozen at what IT was showing.
///
/// A kind that ships its own set for a setting is skipped instead: it does not follow Main, so
/// there is nothing to separate it from, and a copy taken today would outlive every later
/// improvement to that set. For the captions that is both the trade window and a comparison; see
/// [`ChartTabKind::builtin_labels`].
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ChartTabDefaults {
Expand Down
40 changes: 37 additions & 3 deletions crates/moon-core/src/config/chart_defaults/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,50 @@ fn the_tab_kinds_are_every_kind_but_the_trade_window() {
assert_eq!(ChartTabKind::TAB_KINDS.to_vec(), expected);
}

/// Only the trade window ships captions of its own; every other kind follows the Main default.
/// Two kinds ship captions of their own — the trade window and a comparison — because both draw
/// something the live default's figures do not describe: a market that stopped moving hours ago,
/// and the same market several times over in panes a third the width. The other two follow Main.
#[test]
fn only_the_trade_window_ships_its_own_captions() {
fn the_kinds_that_ship_their_own_captions() {
for kind in ChartTabKind::ALL {
let ships = matches!(kind, ChartTabKind::Trade | ChartTabKind::Compare);
assert_eq!(
kind.builtin_labels().is_some(),
kind == ChartTabKind::Trade,
ships,
"{kind:?} disagrees about shipping its own captions"
);
}
// Two sets, not one shared by both: they answer different questions and would have been one
// function if they did not.
assert_ne!(
ChartTabKind::Trade.builtin_labels(),
ChartTabKind::Compare.builtin_labels()
);
// Shared and stable: this is read on every settings comparison, and a fresh clone per read
// would make a panel's signature differ from itself.
assert!(std::ptr::eq(
ChartTabKind::Compare.builtin_labels().expect("a set"),
ChartTabKind::Compare.builtin_labels().expect("a set")
));
}

/// A reset walk visits Main FIRST and still visits every kind.
///
/// Resetting Main separates the kinds that follow it — that is what keeps a press on the main chart
/// from moving a kind the reader never ticked — so a kind the same press also resets has to be
/// emptied AFTER that separation, or it keeps a frozen copy of the value being discarded. The order
/// is the whole mechanism, and the caller that walks it lives in another crate.
#[test]
fn a_reset_walk_starts_at_main_and_reaches_every_kind() {
assert_eq!(ChartTabKind::RESET_ORDER[0], ChartTabKind::Main);
let mut sorted = ChartTabKind::RESET_ORDER.to_vec();
let mut all = ChartTabKind::ALL.to_vec();
sorted.sort_by_key(|k| format!("{k:?}"));
all.sort_by_key(|k| format!("{k:?}"));
assert_eq!(
sorted, all,
"a reset must reach exactly the kinds that exist"
);
}

/// The runtime classifier never produces the trade window: that kind is set by the window itself,
Expand Down
93 changes: 93 additions & 0 deletions crates/moon-core/src/config/chart_labels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,99 @@ impl ChartLabelsCfg {
cfg
}

/// The working set a COMPARISON opens with.
///
/// Its own value rather than [`Self::default`] for the reason the trade window has one: a
/// comparison is READ differently. Several panes of the same coin stand side by side, and the
/// question asked of them is where this venue is against that one — so the figures that
/// describe ONE market in depth are what has to go. The live default's volume block, its
/// measuring block and its session counters are printed three or four times over on such a
/// tab, in panes a third the width, and none of them is what the eye is there for.
///
/// What stays is what a comparison is read by: which venue this pane is, how far its scale
/// reaches, what is open on it, the venue roster, and the spread against the anchor. The last
/// one — [`ChartLabelField::CompareDelta`] — is fed only on a book-only broom follower
/// (`chartdx::text::captions`), so on any other pane it prints nothing and takes no room, the
/// way every optional figure here behaves. It appears in no other shipped set for the same
/// reason.
///
/// Transcribed from the developer's own comparison tab on 2026-09-03, the way
/// [`Self::default`] was transcribed from their main chart: a set that has been USED rather
/// than assembled. Two SIZES come with it, which is where this set parts company with
/// `default`'s "no sizes at all", and they go in OPPOSITE directions on purpose: against
/// [`LABEL_SIZE_MULT_DEFAULT`] the badge is a step up and the venue roster a step down. In a
/// pane a third of the usual width the badge is what a glance checks first, and the roster is
/// a dozen lines that have to fit beside the plot at all. The cost is the one `default`
/// documents — these two captions no longer follow that number if it moves — and it is
/// accepted here because the sizes ARE the layout on a narrow pane.
///
/// It follows the LOCK rather than the pane's width, and that is the intended reading: the
/// anchor lock is a state the reader puts on and takes off, and while it is on the question
/// being asked of the chart is "this venue against that one" whether the pane is a third of the
/// screen or all of it. Locking a single full-width main chart therefore re-dresses it too, and
/// unlocking hands it back the set of the kind its place gives it — nothing is written to the
/// profile either way.
///
/// It is a DEFAULT, not a fixture: the moment the reader sets one for comparisons, theirs is
/// what opens. See [`super::chart_defaults::ChartTabKind::Compare`].
pub fn compare_default() -> Self {
// Stated as STEPS from the shared size rather than as absolutes, so this pair keeps its
// relationship to that number if it ever moves — which is the property `default`'s "no
// sizes at all" protects, kept here at the one place a size is worth spending.
const BADGE_STEP: f32 = 0.2;
const ROSTER_STEP: f32 = 0.25;

let mut cfg = Self::empty();

// The same block the live default opens with, in the control strip: on a tab where every
// pane is the same coin, the venue under it is the pane's whole identity.
cfg.push_prepared(instrument_row(true));

// The Y-scale badge, a step ABOVE the shared size: two panes are only comparable while
// their scales are, and this is the caption that states one. Through the preset rather than
// hand-built, so its band and alignment cannot drift from the catalogue's.
if let Some(ix) = cfg.push_preset(LabelPreset::Scale) {
cfg.rows[ix].parts[0].style.size_mult = Some(LABEL_SIZE_MULT_DEFAULT + BADGE_STEP);
}

// What is open on THIS venue, as one line along the plot's top-left edge: the reason a
// comparison is usually being looked at. Every figure keeps its caption, unlike the live
// default's copy of this module — on a tab of near-identical panes the bare percentage
// beside a bare amount is the one place a reader has to guess which is which.
let mut orders = ChartLabelRow::new(LabelZone::ChartTop, LabelAlign::Left);
orders.preset = Some(LabelPreset::Position);
orders.placement = LabelFlow::Row;
orders.push_part(ChartLabelField::OpenOrders);
orders.push_part(ChartLabelField::OpenPnlMoney);
orders.push_part(ChartLabelField::OpenPnlPct);
orders.push_part(ChartLabelField::Exposure);
cfg.push_prepared(orders);

// The venue roster down the left edge, a step BELOW the shared size: it is the tallest
// module the chart prints and a narrow pane has to hold all of it. Only a spread worth
// acting on is coloured, exactly as the live default sets it.
if let Some(ix) = cfg.push_preset(LabelPreset::Arbitrage) {
let row = &mut cfg.rows[ix];
row.gap = 8;
row.parts[0].style.color = Some(LabelColor::BySign);
row.parts[0].style.color_min_pct = Some(0.5);
row.parts[0].style.size_mult = Some(LABEL_SIZE_MULT_DEFAULT - ROSTER_STEP);
}

// The spread against the anchor, in the strip's bottom band where the pane's own numbers
// end. No preset and no name: the field names itself, and there is no module for it to be
// one of.
cfg.push_row(
ChartLabelField::CompareDelta,
LabelZone::ZoneBottom,
LabelAlign::Right,
);

// Repaired here for [`Self::trade_default`]'s reason: one shape wherever it is compared.
cfg.sanitize();
cfg
}

/// A configuration with no rows at all.
///
/// Public because "print nothing" is a legitimate choice a user can reach by removing every
Expand Down
23 changes: 23 additions & 0 deletions crates/moon-core/src/config/chart_labels/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1446,3 +1446,26 @@ fn a_label_timeframe_round_trips_only_when_it_was_chosen() {
let back: ChartLabelsCfg = serde_json::from_str(&chosen).expect("reads");
assert_eq!(back.rows[0].parts[0].tf, LabelTf::H4);
}

/// Every shipped set must be a fixpoint of `sanitize`.
///
/// These values are COMPARED — a panel's settings signature is rebuilt on every backend
/// notification, and one side of that comparison has been through the repair while the other has
/// not. A shipped set that `sanitize` still moves would therefore differ from itself forever, which
/// is a repaint per notification rather than a wrong pixel: the failure this asserts against is
/// silent and permanent.
#[test]
fn the_shipped_sets_survive_their_own_repair() {
for (name, shipped) in [
("default", ChartLabelsCfg::default()),
("trade", ChartLabelsCfg::trade_default()),
("compare", ChartLabelsCfg::compare_default()),
] {
let mut repaired = shipped.clone();
repaired.sanitize();
assert_eq!(
repaired, shipped,
"the {name} set is not a sanitize fixpoint"
);
}
}
Loading