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

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