From c1d5f7950271b97747671397b1fd868b7ee9526a Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 3 Jul 2026 14:50:17 -0500 Subject: [PATCH] feat(market): real historical sparklines via CoinGecko market_chart Portfolio-group sparklines previously resampled the ~24-minute in-session price buffer, so the hour/day/week/month intervals could never fill. Fetch real historical USD series from CoinGecko /market_chart and back each interval with appropriately-grained data. - MarketInfo gains two timestamped series: price_chart_intraday (days=1, 5-min) and price_chart_daily (days=365, daily), plus a fetch timestamp. - App::refreshMarketChart() fetches both on the RPC worker via the TLS-verifying util::httpGetString helper, self-throttled to ~30 min (historical data moves slowly). Gated by getFetchPrices(); triggered on connect and each price tick. - Pure NetworkRefreshService::parseCoinGeckoMarketChart() parses {"prices": [[ms,price],...]} into (unix-seconds, price); malformed rows skipped. Unit-tested. - market_tab pfSparklineSeries() maps interval -> series+bucket: minute = live buffer; hour = intraday bucketed to 1h; day/week/month = daily bucketed to 1d/7d/30d. Falls back to the in-session buffer until the fetch populates. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 1 + src/app.h | 4 +++ src/app_network.cpp | 41 ++++++++++++++++++++++++ src/data/wallet_state.h | 16 +++++++-- src/services/network_refresh_service.cpp | 23 +++++++++++++ src/services/network_refresh_service.h | 4 +++ src/ui/windows/market_tab.cpp | 39 ++++++++++++++++++++-- tests/test_phase4.cpp | 20 ++++++++++++ 8 files changed, 144 insertions(+), 4 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 3fa6f55..4f2af83 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1111,6 +1111,7 @@ void App::update() if (network_refresh_.consumeDue(RefreshTimer::Price)) { if (settings_->getFetchPrices()) { refreshPrice(); + refreshMarketChart(); // self-throttled to ~30 min; keeps sparkline history fresh } } diff --git a/src/app.h b/src/app.h index 7434aa6..1c5318f 100644 --- a/src/app.h +++ b/src/app.h @@ -295,6 +295,9 @@ public: // Fetch the live exchange/pair list from CoinGecko once per session (venues are // near-static); populates state.market.exchanges. Safe to call every frame. void refreshExchanges(); + // Fetch historical USD price series (CoinGecko market_chart) that back the portfolio + // sparkline intervals; self-throttled to ~30 min. Safe to call every frame. + void refreshMarketChart(); /// @brief Per-category refresh intervals, adjusted by active tab using RefreshIntervals = services::NetworkRefreshService::Intervals; @@ -567,6 +570,7 @@ private: bool balance_refresh_pending_ = false; // UI asked for an immediate refresh bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied bool exchanges_fetch_started_ = false; // once-per-session CoinGecko tickers fetch + bool chart_fetch_in_flight_ = false; // a market_chart history fetch is on the worker util::AsyncTaskManager async_tasks_; bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog diff --git a/src/app_network.cpp b/src/app_network.cpp index c4a0f17..96f5902 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -42,6 +42,7 @@ #include "ui/notifications.h" #include "default_banlist_embedded.h" #include "util/amount_format.h" +#include "util/http_download.h" #include "util/platform.h" #include "util/perf_log.h" #include "util/i18n.h" @@ -1621,6 +1622,46 @@ void App::refreshMarketData() { refreshPrice(); refreshExchanges(); + refreshMarketChart(); +} + +void App::refreshMarketChart() +{ + if (!settings_ || !settings_->getFetchPrices()) return; + if (!worker_) return; + if (chart_fetch_in_flight_) return; + // Historical data moves slowly — refresh at most every 30 minutes after the first fetch. + if (state_.market.chart_loaded && + std::chrono::steady_clock::now() - state_.market.chart_last_fetch_time < std::chrono::minutes(30)) + return; + chart_fetch_in_flight_ = true; + + worker_->post([this]() -> rpc::RPCWorker::MainCb { + // Two ranges give real granularity across every sparkline interval: + // days=1 -> 5-minute samples (backs the "hour" interval) + // days=365 -> daily samples (backs "day"/"week"/"month") + // httpGetString verifies TLS and returns "" on any failure (parses to an empty series). + std::string intradayBody = util::httpGetString( + "https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=1", + "[market-chart]"); + std::string dailyBody = util::httpGetString( + "https://api.coingecko.com/api/v3/coins/dragonx-2/market_chart?vs_currency=usd&days=365", + "[market-chart]"); + auto intraday = NetworkRefreshService::parseCoinGeckoMarketChart(intradayBody); + auto daily = NetworkRefreshService::parseCoinGeckoMarketChart(dailyBody); + + return [this, intraday = std::move(intraday), daily = std::move(daily)]() mutable { + chart_fetch_in_flight_ = false; + const bool any = !intraday.empty() || !daily.empty(); + if (!intraday.empty()) state_.market.price_chart_intraday = std::move(intraday); + if (!daily.empty()) state_.market.price_chart_daily = std::move(daily); + if (any) { + state_.market.chart_loaded = true; + state_.market.chart_last_fetch_time = std::chrono::steady_clock::now(); + } + // On total failure, leave chart_loaded=false so the next tick retries. + }; + }); } void App::refreshExchanges() diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 1a51b81..f44ff1a 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "exchange_info.h" @@ -159,9 +161,19 @@ struct MarketInfo { bool price_loading = false; std::string price_error; - // Price history for chart + // Live in-session price history: ~1 sample/minute, capped at MAX_HISTORY samples. + // Backs the main chart and the "minute" portfolio-sparkline interval. std::vector price_history; - static constexpr int MAX_HISTORY = 24; // 24 hours + static constexpr int MAX_HISTORY = 24; // 24 samples (~24 minutes at the 60s refresh) + + // Historical USD price series fetched from CoinGecko market_chart, so the portfolio-group + // sparklines can show real hour/day/week/month trends instead of resampling the ~24-minute + // in-session buffer. Timestamped (unix seconds), oldest->newest; empty until the first fetch. + // Refreshed on a slow cadence (~30 min) since historical data moves slowly. + std::vector> price_chart_intraday; // ~24h @ 5-minute granularity + std::vector> price_chart_daily; // ~1yr @ daily granularity + std::chrono::steady_clock::time_point chart_last_fetch_time{}; + bool chart_loaded = false; // Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab // falls back to data::getExchangeRegistry() while empty). diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 2865107..8d1cf3b 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -458,6 +458,29 @@ std::optional NetworkRefreshService:: } } +std::vector> NetworkRefreshService::parseCoinGeckoMarketChart( + const std::string& response) +{ + std::vector> series; + try { + auto parsed = json::parse(response); + if (!parsed.contains("prices") || !parsed["prices"].is_array()) return series; + const auto& prices = parsed["prices"]; + series.reserve(prices.size()); + for (const auto& row : prices) { + // Each row is [milliseconds, price]; skip anything malformed. + if (!row.is_array() || row.size() < 2) continue; + if (!row[0].is_number() || !row[1].is_number()) continue; + const double ms = row[0].get(); + const double price = row[1].get(); + series.emplace_back(static_cast(ms / 1000.0), price); + } + } catch (...) { + series.clear(); + } + return series; +} + NetworkRefreshService::PriceHttpResult NetworkRefreshService::parsePriceHttpResponse( const PriceHttpResponse& response, std::time_t fetchedAt) diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index fd779e4..efdf024 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -250,6 +250,10 @@ public: std::time_t fetchedAt); static PriceHttpResult parsePriceHttpResponse(const PriceHttpResponse& response, std::time_t fetchedAt); + // Parse a CoinGecko /market_chart response ({"prices":[[ms,price],...]}) into a timestamped + // (unix seconds, USD price) series, oldest->newest. Malformed rows are skipped; on any error + // (or a missing "prices" array) an empty vector is returned. Pure/no-I/O. + static std::vector> parseCoinGeckoMarketChart(const std::string& response); static AddressInfo buildShieldedAddressInfo(const std::string& address, const nlohmann::json& validation, bool validationSucceeded); diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index c87297f..8b5a32f 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -129,6 +129,41 @@ static std::vector pfResampleHistory(const std::vector& hist, in 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); +} + // 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. static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx, @@ -354,7 +389,7 @@ static void RenderPortfolioEditor(App* app) ImVec2(bx + 3.0f, pMax.y - ppad * 0.6f), accent, 2.0f); } if (s_pf_show_sparkline && (s_pf_price_basis == 0 || s_pf_price_basis == 1)) { - std::vector h = pfResampleHistory(state.market.price_history, s_pf_spark_interval); + std::vector h = pfSparklineSeries(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 + (accent ? 6.0f : 0.0f), pMin.y + ph * 0.46f); @@ -1481,7 +1516,7 @@ void RenderMarketTab(App* app) // Sparkline strip along the bottom. float sparkH = 0.0f; if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) { - std::vector hh = pfResampleHistory(market.price_history, e.sparklineInterval); + std::vector hh = pfSparklineSeries(market, e.sparklineInterval); if (hh.size() >= 2) { sparkH = std::max(18.0f * mktDp, (gcMax.y - gcMin.y) * 0.24f); pfDrawSparklineFilled(dl, ImVec2(left, gcMax.y - pad - sparkH), ImVec2(right, gcMax.y - pad), diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 7a4588e..60e0ede 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -1896,6 +1896,26 @@ void testNetworkRefreshResultModels() EXPECT_FALSE(priceHttpParseResult.price.has_value()); EXPECT_EQ(priceHttpParseResult.errorMessage, std::string("Price fetch returned an unrecognized response")); + // CoinGecko market_chart parser: [ms, price] rows -> (seconds, price), oldest->newest. + auto chart = Refresh::parseCoinGeckoMarketChart( + R"({"prices":[[1700000000000,1.5],[1700003600000,1.6],[1700007200000,1.55]],)" + R"("market_caps":[[1700000000000,100]],"total_volumes":[[1700000000000,10]]})"); + EXPECT_EQ(chart.size(), static_cast(3)); + if (chart.size() == 3) { + EXPECT_EQ(chart[0].first, static_cast(1700000000)); + EXPECT_NEAR(chart[0].second, 1.5, 0.00000001); + EXPECT_EQ(chart[2].first, static_cast(1700007200)); + EXPECT_NEAR(chart[2].second, 1.55, 0.00000001); + } + // Malformed rows are skipped; a good row still comes through. + auto chartSkip = Refresh::parseCoinGeckoMarketChart( + R"({"prices":[[1700000000000],["x",1.0],[1700003600000,2.0]]})"); + EXPECT_EQ(chartSkip.size(), static_cast(1)); + if (chartSkip.size() == 1) EXPECT_NEAR(chartSkip[0].second, 2.0, 0.00000001); + // Missing/!array "prices" and invalid JSON both yield an empty series. + EXPECT_EQ(Refresh::parseCoinGeckoMarketChart(R"({"nope":[]})").size(), static_cast(0)); + EXPECT_EQ(Refresh::parseCoinGeckoMarketChart("not json").size(), static_cast(0)); + Refresh::AddressRefreshResult addresses; dragonx::AddressInfo zAddr; zAddr.address = "zs-refresh";