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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user