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) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 14:50:17 -05:00
parent db212a81c6
commit c1d5f79502
8 changed files with 144 additions and 4 deletions

View File

@@ -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
}
}

View File

@@ -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

View File

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

View File

@@ -8,6 +8,8 @@
#include <vector>
#include <cstdint>
#include <chrono>
#include <ctime>
#include <utility>
#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<double> 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<std::pair<std::time_t, double>> price_chart_intraday; // ~24h @ 5-minute granularity
std::vector<std::pair<std::time_t, double>> 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).

View File

@@ -458,6 +458,29 @@ std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::
}
}
std::vector<std::pair<std::time_t, double>> NetworkRefreshService::parseCoinGeckoMarketChart(
const std::string& response)
{
std::vector<std::pair<std::time_t, double>> 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<double>();
const double price = row[1].get<double>();
series.emplace_back(static_cast<std::time_t>(ms / 1000.0), price);
}
} catch (...) {
series.clear();
}
return series;
}
NetworkRefreshService::PriceHttpResult NetworkRefreshService::parsePriceHttpResponse(
const PriceHttpResponse& response,
std::time_t fetchedAt)

View File

@@ -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<std::pair<std::time_t, double>> parseCoinGeckoMarketChart(const std::string& response);
static AddressInfo buildShieldedAddressInfo(const std::string& address,
const nlohmann::json& validation,
bool validationSucceeded);

View File

@@ -129,6 +129,41 @@ static std::vector<double> pfResampleHistory(const std::vector<double>& 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<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);
}
// 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<double> h = pfResampleHistory(state.market.price_history, s_pf_spark_interval);
std::vector<double> 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<double> hh = pfResampleHistory(market.price_history, e.sparklineInterval);
std::vector<double> 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),

View File

@@ -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<size_t>(3));
if (chart.size() == 3) {
EXPECT_EQ(chart[0].first, static_cast<std::time_t>(1700000000));
EXPECT_NEAR(chart[0].second, 1.5, 0.00000001);
EXPECT_EQ(chart[2].first, static_cast<std::time_t>(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<size_t>(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<size_t>(0));
EXPECT_EQ(Refresh::parseCoinGeckoMarketChart("not json").size(), static_cast<size_t>(0));
Refresh::AddressRefreshResult addresses;
dragonx::AddressInfo zAddr;
zAddr.address = "zs-refresh";