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) <noreply@anthropic.com>
This commit is contained in:
99
src/data/market_series.h
Normal file
99
src/data/market_series.h
Normal file
@@ -0,0 +1,99 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
#include <ctime>
|
||||
|
||||
#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<double> resampleHistory(const std::vector<double>& 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<double> 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<double> bucketBySeconds(
|
||||
const std::vector<std::pair<std::time_t, double>>& series, long windowSec)
|
||||
{
|
||||
std::vector<double> 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<double> 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<std::pair<std::time_t, double>> chartSeries(const MarketInfo& m, int interval,
|
||||
std::time_t now)
|
||||
{
|
||||
const long kDay = 86400;
|
||||
auto lastWindow = [now](const std::vector<std::pair<std::time_t, double>>& src, long rangeSec) {
|
||||
std::vector<std::pair<std::time_t, double>> 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<std::pair<std::time_t, double>> 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
|
||||
@@ -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<double> pfResampleHistory(const std::vector<double>& 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<double> 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<double> pfBucketBySeconds(
|
||||
const std::vector<std::pair<std::time_t, double>>& series, long windowSec)
|
||||
{
|
||||
std::vector<double> 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<double> 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<std::pair<std::time_t, double>> pfChartSeries(const MarketInfo& m, int interval,
|
||||
std::time_t now)
|
||||
{
|
||||
const long kDay = 86400;
|
||||
auto lastWindow = [now](const std::vector<std::pair<std::time_t, double>>& src, long rangeSec) {
|
||||
std::vector<std::pair<std::time_t, double>> 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<std::pair<std::time_t, double>> 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<double> h = pfSparklineSeries(state.market, s_pf_spark_interval);
|
||||
std::vector<double> 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<std::time_t> 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<double> hh = pfSparklineSeries(market, e.sparklineInterval);
|
||||
std::vector<double> hh = data::sparklineSeries(market, e.sparklineInterval);
|
||||
if (hh.size() >= 2) {
|
||||
float sparkTop = std::max(leftBottom, rightBottom) + Layout::spacingXs();
|
||||
float sparkBot = gcMax.y - hpad;
|
||||
|
||||
@@ -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 <chrono>
|
||||
@@ -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<double> minute = {1.0, 2.0, 3.0, 4.0};
|
||||
EXPECT_EQ(resampleHistory(minute, 0).size(), static_cast<size_t>(4));
|
||||
EXPECT_NEAR(resampleHistory(minute, 0)[3], 4.0, 1e-9);
|
||||
// interval 1 (hour = 60 minute-samples): 120 samples -> 2 averaged points.
|
||||
std::vector<double> 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<size_t>(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<double>(61, 5.0), 1).size(), static_cast<size_t>(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<std::pair<std::time_t, double>> 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<size_t>(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<size_t>(2));
|
||||
// ...interval 0 (minute) always uses the live buffer...
|
||||
EXPECT_EQ(sparklineSeries(m, 0).size(), static_cast<size_t>(3));
|
||||
// ...and with no historical series, an interval falls back to the resampled live buffer.
|
||||
MarketInfo bare; bare.price_history = std::vector<double>(120, 7.0);
|
||||
EXPECT_EQ(sparklineSeries(bare, 1).size(), static_cast<size_t>(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<size_t>(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<size_t>(3));
|
||||
EXPECT_EQ(static_cast<long>(live.back().first), static_cast<long>(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();
|
||||
|
||||
Reference in New Issue
Block a user