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:
2026-07-04 00:03:19 -05:00
parent 14b08d1358
commit a0fd9aff82
3 changed files with 175 additions and 84 deletions

View File

@@ -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();