From a0fd9aff82bab0d0ac939dfee98770dd437f2822 Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 4 Jul 2026 00:03:19 -0500 Subject: [PATCH] refactor(market): extract pure price-series math to data/market_series.h + tests Move the no-I/O, no-ImGui series helpers (resampleHistory, bucketBySeconds, sparklineSeries, chartSeries) out of market_tab.cpp into an inline header under data/ (mirroring data/portfolio.h) and cover them with a testMarketSeries() group in test_phase4.cpp (resample block averaging + partial blocks, window bucketing + guards, sparkline fallback, chart time-window filtering + live timestamp synthesis). market_tab now calls data::sparklineSeries/chartSeries. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/data/market_series.h | 99 +++++++++++++++++++++++++++++++++++ src/ui/windows/market_tab.cpp | 90 +++---------------------------- tests/test_phase4.cpp | 70 +++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 84 deletions(-) create mode 100644 src/data/market_series.h diff --git a/src/data/market_series.h b/src/data/market_series.h new file mode 100644 index 0000000..d5af3eb --- /dev/null +++ b/src/data/market_series.h @@ -0,0 +1,99 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#pragma once + +#include +#include +#include + +#include "wallet_state.h" // MarketInfo + +// Pure (no-I/O, no-ImGui) price-series math backing the market chart and the portfolio-group +// sparklines. Kept header-only + inline so it can be unit-tested without the GUI (see +// tests/test_phase4.cpp), mirroring data/portfolio.h. +namespace dragonx { +namespace data { + +// Resample a ~1-sample/minute price history to the given interval by averaging each block of K +// minute-samples into one point. interval: 0=min 1=hour 2=day 3=week 4=month. +inline std::vector resampleHistory(const std::vector& hist, int interval) +{ + static const int kMins[5] = {1, 60, 1440, 10080, 43200}; + int k = kMins[(interval >= 0 && interval < 5) ? interval : 0]; + if (k <= 1) return hist; + std::vector out; + for (size_t i = 0; i < hist.size(); i += (size_t)k) { + double sum = 0.0; size_t cnt = 0; + for (size_t j = i; j < hist.size() && j < i + (size_t)k; ++j) { sum += hist[j]; ++cnt; } + if (cnt) out.push_back(sum / (double)cnt); + } + return out; +} + +// Bucket a timestamped (unix-seconds, price) series into fixed-width time windows, averaging the +// samples in each window into one point. Input is oldest->newest; output preserves that order. +inline std::vector bucketBySeconds( + const std::vector>& series, long windowSec) +{ + std::vector out; + if (series.empty() || windowSec <= 0) return out; + long curBucket = 0; double sum = 0.0; int cnt = 0; + for (const auto& sample : series) { + long b = (long)(sample.first / windowSec); + if (cnt > 0 && b != curBucket) { out.push_back(sum / cnt); sum = 0.0; cnt = 0; } + curBucket = b; + sum += sample.second; ++cnt; + } + if (cnt > 0) out.push_back(sum / cnt); + return out; +} + +// Resolve the price series backing a group's sparkline for the chosen interval: +// 0=minute -> the live in-session buffer; 1=hour -> intraday (5-min) data bucketed to hours; +// 2=day / 3=week / 4=month -> the ~1yr daily series bucketed accordingly. +// Falls back to the in-session buffer when the historical fetch hasn't populated yet. +inline std::vector sparklineSeries(const MarketInfo& m, int interval) +{ + const long kDay = 86400; + switch (interval) { + case 1: { auto v = bucketBySeconds(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } + case 2: { auto v = bucketBySeconds(m.price_chart_daily, kDay); if (v.size() >= 2) return v; break; } + case 3: { auto v = bucketBySeconds(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; } + case 4: { auto v = bucketBySeconds(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; } + default: break; // minute interval, or no historical data yet -> live buffer below + } + return resampleHistory(m.price_history, interval); +} + +// Timestamped price series backing the main chart for the selected RANGE (Live/1H/1D/1W/1M), +// ending at `now`. The interval buttons select a time window ending at `now`: 1H = last hour, +// 1D = last 24h, 1W = last 7 days, 1M = last 30 days. 1H/1D come from the 5-minute intraday +// series; 1W/1M from the daily series. Live (or an un-fetched/empty range) uses the minute buffer. +inline std::vector> chartSeries(const MarketInfo& m, int interval, + std::time_t now) +{ + const long kDay = 86400; + auto lastWindow = [now](const std::vector>& src, long rangeSec) { + std::vector> out; + std::time_t cutoff = now - (std::time_t)rangeSec; + for (const auto& s : src) if (s.first >= cutoff) out.push_back(s); + return out; + }; + switch (interval) { + case 1: { auto v = lastWindow(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } // 1H + case 2: { auto v = lastWindow(m.price_chart_intraday, kDay); if (v.size() >= 2) return v; break; } // 1D + case 3: { auto v = lastWindow(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W + case 4: { auto v = lastWindow(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M + default: break; + } + std::vector> out; + const auto& h = m.price_history; + for (size_t i = 0; i < h.size(); i++) + out.push_back({ now - (std::time_t)((h.size() - 1 - i) * 60), h[i] }); + return out; +} + +} // namespace data +} // namespace dragonx diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index b04676d..1dce347 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -8,6 +8,7 @@ #include "../../data/wallet_state.h" #include "../../config/settings.h" #include "../../data/portfolio.h" +#include "../../data/market_series.h" #include "../../data/exchange_info.h" #include "../../util/i18n.h" #include "../schema/ui_schema.h" @@ -174,89 +175,10 @@ static void pfEndScrollChild(effects::ScrollFadeShader& fade, ImDrawList* dl) ImGui::EndChild(); // outer } -// price_history is ~1 sample/minute; resample it to the group's chosen interval by averaging -// each block of K minute-samples into one point. -static std::vector pfResampleHistory(const std::vector& hist, int interval) -{ - static const int kMins[5] = {1, 60, 1440, 10080, 43200}; - int k = kMins[(interval >= 0 && interval < 5) ? interval : 0]; - if (k <= 1) return hist; - std::vector out; - for (size_t i = 0; i < hist.size(); i += (size_t)k) { - double sum = 0.0; size_t cnt = 0; - for (size_t j = i; j < hist.size() && j < i + (size_t)k; ++j) { sum += hist[j]; ++cnt; } - if (cnt) out.push_back(sum / (double)cnt); - } - return out; -} - -// Bucket a timestamped (unix-seconds, price) series into fixed-width time windows, averaging the -// samples in each window into one point. Input is oldest->newest; output preserves that order. -static std::vector pfBucketBySeconds( - const std::vector>& series, long windowSec) -{ - std::vector out; - if (series.empty() || windowSec <= 0) return out; - long curBucket = 0; double sum = 0.0; int cnt = 0; - for (const auto& sample : series) { - long b = (long)(sample.first / windowSec); - if (cnt > 0 && b != curBucket) { out.push_back(sum / cnt); sum = 0.0; cnt = 0; } - curBucket = b; - sum += sample.second; ++cnt; - } - if (cnt > 0) out.push_back(sum / cnt); - return out; -} - -// Resolve the price series backing a group's sparkline for the chosen interval: -// 0=minute -> the live in-session buffer; 1=hour -> intraday (5-min) data bucketed to hours; -// 2=day / 3=week / 4=month -> the ~1yr daily series bucketed accordingly. -// Falls back to the in-session buffer when the historical fetch hasn't populated yet. -static std::vector pfSparklineSeries(const MarketInfo& m, int interval) -{ - const long kDay = 86400; - switch (interval) { - case 1: { auto v = pfBucketBySeconds(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } - case 2: { auto v = pfBucketBySeconds(m.price_chart_daily, kDay); if (v.size() >= 2) return v; break; } - case 3: { auto v = pfBucketBySeconds(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; } - case 4: { auto v = pfBucketBySeconds(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; } - default: break; // minute interval, or no historical data yet -> live buffer below - } - return pfResampleHistory(m.price_history, interval); -} - -// Main price-chart interval selector (same semantics as pfSparklineSeries): -// 0=Live (in-session minute buffer) 1=hour 2=day 3=week 4=month. Session-scoped. +// Main price-chart interval selector: 0=Live (in-session minute buffer) 1=hour 2=day 3=week +// 4=month. Session-scoped. The pure series math lives in data/market_series.h. static int s_chart_interval = 4; // default: 1M (last 30 days) -// Timestamped price series backing the main chart for the selected RANGE (Live/1H/1D/1W/1M). -// The interval buttons select a time window ending at `now`: 1H = last hour, 1D = last 24h, -// 1W = last 7 days, 1M = last 30 days. 1H/1D come from the 5-minute intraday series; 1W/1M from -// the daily series. Live (or an un-fetched/empty range) uses the in-session minute buffer. -static std::vector> pfChartSeries(const MarketInfo& m, int interval, - std::time_t now) -{ - const long kDay = 86400; - auto lastWindow = [now](const std::vector>& src, long rangeSec) { - std::vector> out; - std::time_t cutoff = now - (std::time_t)rangeSec; - for (const auto& s : src) if (s.first >= cutoff) out.push_back(s); - return out; - }; - switch (interval) { - case 1: { auto v = lastWindow(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } // 1H - case 2: { auto v = lastWindow(m.price_chart_intraday, kDay); if (v.size() >= 2) return v; break; } // 1D - case 3: { auto v = lastWindow(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W - case 4: { auto v = lastWindow(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M - default: break; - } - std::vector> out; - const auto& h = m.price_history; - for (size_t i = 0; i < h.size(); i++) - out.push_back({ now - (std::time_t)((h.size() - 1 - i) * 60), h[i] }); - return out; -} - // A group's value trend tracks the DRGX price, so a group sparkline plots the price history // (normalized) across a rect. Colored by overall direction. Only meaningful for market bases. @@ -732,7 +654,7 @@ static void RenderPortfolioEditor(App* app) pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f, 0, 2.0f); } if (s_pf_show_sparkline && (s_pf_price_basis == 0 || s_pf_price_basis == 1)) { - std::vector h = pfSparklineSeries(state.market, s_pf_spark_interval); + std::vector h = data::sparklineSeries(state.market, s_pf_spark_interval); if (h.size() >= 2) { ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80); ImVec2 spMin(pMin.x + ppad, pMin.y + ph * 0.46f); @@ -1322,7 +1244,7 @@ void RenderMarketTab(App* app) std::time_t nowSec = std::time(nullptr); std::vector chartTimes; s_price_history.clear(); - for (const auto& pr : pfChartSeries(market, s_chart_interval, nowSec)) { + for (const auto& pr : data::chartSeries(market, s_chart_interval, nowSec)) { s_price_history.push_back(pr.second); chartTimes.push_back(pr.first); } @@ -1993,7 +1915,7 @@ void RenderMarketTab(App* app) // Body: sparkline fills from below the header to the card bottom. if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) { - std::vector hh = pfSparklineSeries(market, e.sparklineInterval); + std::vector hh = data::sparklineSeries(market, e.sparklineInterval); if (hh.size() >= 2) { float sparkTop = std::max(leftBottom, rightBottom) + Layout::spacingXs(); float sparkBot = gcMax.y - hpad; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 60e0ede..c2956f9 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -40,6 +40,7 @@ #include "config/settings.h" #include "data/wallet_state.h" #include "data/portfolio.h" +#include "data/market_series.h" #include "fake_lite_backend.h" #include @@ -2735,6 +2736,74 @@ void testPortfolioHelpers() EXPECT_EQ(group[0], std::string("R-t2")); } +// Market price-series math (data/market_series.h) — minute-resampling + time-window bucketing +// that feed the price chart and the portfolio-group sparklines. +void testMarketSeries() +{ + using dragonx::MarketInfo; + using dragonx::data::resampleHistory; + using dragonx::data::bucketBySeconds; + using dragonx::data::sparklineSeries; + using dragonx::data::chartSeries; + + // resampleHistory: interval 0 (minute) returns the input untouched. + std::vector minute = {1.0, 2.0, 3.0, 4.0}; + EXPECT_EQ(resampleHistory(minute, 0).size(), static_cast(4)); + EXPECT_NEAR(resampleHistory(minute, 0)[3], 4.0, 1e-9); + // interval 1 (hour = 60 minute-samples): 120 samples -> 2 averaged points. + std::vector longHist(120, 0.0); + for (int i = 0; i < 120; i++) longHist[i] = (i < 60) ? 10.0 : 20.0; + auto hourly = resampleHistory(longHist, 1); + EXPECT_EQ(hourly.size(), static_cast(2)); + EXPECT_NEAR(hourly[0], 10.0, 1e-9); + EXPECT_NEAR(hourly[1], 20.0, 1e-9); + // A trailing partial block still averages (61 samples -> 2 points). + EXPECT_EQ(resampleHistory(std::vector(61, 5.0), 1).size(), static_cast(2)); + // Empty input -> empty output. + EXPECT_TRUE(resampleHistory({}, 2).empty()); + + // bucketBySeconds: guards + averaging within fixed windows. + EXPECT_TRUE(bucketBySeconds({}, 3600).empty()); + EXPECT_TRUE(bucketBySeconds({{0, 1.0}}, 0).empty()); + std::vector> s = { + {0, 10.0}, {1000, 20.0}, // bucket 0 (< 3600s) -> avg 15 + {3600, 40.0}, {7000, 60.0}, // bucket 1 -> avg 50 + }; + auto b = bucketBySeconds(s, 3600); + EXPECT_EQ(b.size(), static_cast(2)); + EXPECT_NEAR(b[0], 15.0, 1e-9); + EXPECT_NEAR(b[1], 50.0, 1e-9); + + // sparklineSeries: hour interval buckets the intraday series... + MarketInfo m; + m.price_history = {1.0, 2.0, 3.0}; + m.price_chart_intraday = { {0, 10.0}, {100, 12.0}, {3600, 20.0}, {3700, 22.0} }; // 2 hourly buckets + EXPECT_EQ(sparklineSeries(m, 1).size(), static_cast(2)); + // ...interval 0 (minute) always uses the live buffer... + EXPECT_EQ(sparklineSeries(m, 0).size(), static_cast(3)); + // ...and with no historical series, an interval falls back to the resampled live buffer. + MarketInfo bare; bare.price_history = std::vector(120, 7.0); + EXPECT_EQ(sparklineSeries(bare, 1).size(), static_cast(2)); + + // chartSeries: the 1H window keeps only samples within the last hour of `now`. + std::time_t now = 100000; + MarketInfo cm; + cm.price_chart_intraday = { + {now - 7200, 1.0}, // 2h ago -> excluded from 1H + {now - 1800, 2.0}, // 30m ago -> included + {now - 60, 3.0}, // 1m ago -> included + }; + auto oneH = chartSeries(cm, 1, now); + EXPECT_EQ(oneH.size(), static_cast(2)); + EXPECT_NEAR(oneH[0].second, 2.0, 1e-9); + // Live (interval 0) synthesizes timestamps from the minute buffer, newest at `now`. + MarketInfo lm; lm.price_history = {1.0, 2.0, 3.0}; + auto live = chartSeries(lm, 0, now); + EXPECT_EQ(live.size(), static_cast(3)); + EXPECT_EQ(static_cast(live.back().first), static_cast(now)); + EXPECT_NEAR(live.back().second, 3.0, 1e-9); +} + // Regression: the Market-tab portfolio (and other consumers) read the combined // WalletState::addresses view, which the full-node refresh path builds from the // authoritative z/t lists via rebuildAddressList(). Before the fix that rebuild @@ -5507,6 +5576,7 @@ int main() testConsoleTextLayout(); testConsoleModel(); testPortfolioHelpers(); + testMarketSeries(); testWalletStateAddressListRebuild(); testConsoleFoldSpans(); testConsoleScrollController();