feat(market): per-exchange price chart from each venue's own candle API

The market chart was hardwired to CoinGecko's cross-exchange USD aggregate
(vs_currency=usd), so selecting a trading pair only changed the "Trade" link —
never the chart or price. Now the SELECTED exchange drives both.

CoinGecko gives us which venues list DRGX (the ticker `market.identifier`) but
not their APIs, so add data/exchange_candles.h: a hand-maintained map from that
identifier to each venue's public candle endpoint, with two adapters verified
against the live APIs — Ourbit (MEXC-style /api/v3/klines, array format) and
NonKYC (TradingView-UDF /market/candles, `bars`). Unmapped venues return no URL,
so the chart falls back to the CoinGecko aggregate and nothing regresses.

- App::refreshExchangeChart() resolves the selected pair, fetches its intraday
  (5-min) + daily candles via the TLS-verified httpGetString on the worker, and
  stores them on MarketInfo (exchange_chart_intraday/daily + exchange_chart_active).
  Re-fetches on pair change; self-throttled to ~30 min otherwise; falls back to
  aggregate on any failure.
- chartSeries() draws the per-exchange series when active (portfolio sparklines
  stay on the aggregate). The hero shows the selected venue's real price
  (converted_last) — Ourbit vs NonKYC differ ~7%. parseCoinGeckoTickers now
  captures the identifier + per-exchange USD price (previously discarded).

Adapters unit-tested against real captured responses (URL builder + both parsers
+ the chartSeries switch) and validated against the full live feeds (Ourbit 20d,
NonKYC 370d, ascending, correct closes). Build + ctest + hygiene green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 09:19:55 -05:00
parent 156735eb06
commit d1ff58c374
9 changed files with 278 additions and 13 deletions

View File

@@ -335,7 +335,11 @@ public:
// 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();
// Fetch the SELECTED pair's candles from that exchange's own API (data/exchange_candles.h) so the
// Market chart shows the real per-exchange price. Re-fetches on pair change; falls back to the
// CoinGecko aggregate for unmapped venues / failed fetches. Safe to call every frame.
void refreshExchangeChart();
/// @brief Per-category refresh intervals, adjusted by active tab
using RefreshIntervals = services::NetworkRefreshService::Intervals;
@@ -675,6 +679,11 @@ private:
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
// Per-exchange candle chart (refreshExchangeChart): which pair the loaded series is for, an
// in-flight guard, and a slow refresh timer (candles move slowly, like the aggregate chart).
std::string exchange_chart_key_; // "<identifier>:<BASE>/<QUOTE>" of the loaded series ("" = none)
bool exchange_chart_fetch_in_flight_ = false;
std::chrono::steady_clock::time_point exchange_chart_last_fetch_{};
util::AsyncTaskManager async_tasks_;
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog

View File

@@ -50,6 +50,8 @@
#include "default_banlist_embedded.h"
#include "util/amount_format.h"
#include "util/http_download.h"
#include "data/exchange_info.h"
#include "data/exchange_candles.h"
#include "util/platform.h"
#include "util/perf_log.h"
#include "util/i18n.h"
@@ -1768,6 +1770,72 @@ void App::refreshMarketData()
refreshPrice();
refreshExchanges();
refreshMarketChart();
refreshExchangeChart();
}
void App::refreshExchangeChart()
{
if (!settings_ || !settings_->getFetchPrices() || !worker_) return;
if (exchange_chart_fetch_in_flight_) return;
// Resolve the selected pair from the same registry the Market tab renders (live tickers, else the
// static fallback), matching exchange name THEN pair to disambiguate the same pair across venues.
const auto& reg = state_.market.exchanges.empty()
? data::getExchangeRegistry() : state_.market.exchanges;
const std::string selEx = settings_->getSelectedExchange();
const std::string selPair = settings_->getSelectedPair();
const data::ExchangePair* pair = nullptr;
for (const auto& ex : reg) {
if (!selEx.empty() && ex.name != selEx) continue;
for (const auto& p : ex.pairs)
if (p.displayName == selPair) { pair = &p; break; }
if (pair) break;
}
if (!pair && !reg.empty() && !reg[0].pairs.empty()) pair = &reg[0].pairs[0]; // default like the UI
if (!pair) { state_.market.exchange_chart_active = false; exchange_chart_key_.clear(); return; }
const std::string key = pair->identifier + ":" + pair->base + "/" + pair->quote;
// No candle adapter for this venue -> draw the CoinGecko aggregate instead.
if (pair->identifier.empty() || !data::hasExchangeCandleAdapter(pair->identifier)) {
state_.market.exchange_chart_active = false;
exchange_chart_key_.clear();
return;
}
// Already loaded for this pair and still fresh (candles move slowly).
if (key == exchange_chart_key_ && state_.market.exchange_chart_active &&
std::chrono::steady_clock::now() - exchange_chart_last_fetch_ < std::chrono::minutes(30))
return;
// Switching pairs: fall back to the aggregate until the new venue's candles arrive.
if (key != exchange_chart_key_) state_.market.exchange_chart_active = false;
exchange_chart_fetch_in_flight_ = true;
const std::string identifier = pair->identifier, base = pair->base, quote = pair->quote;
const std::time_t now = std::time(nullptr);
const std::string urlIntra = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Intraday, now);
const std::string urlDaily = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Daily, now);
worker_->post([this, identifier, key, urlIntra, urlDaily]() -> rpc::RPCWorker::MainCb {
// httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
std::string bIntra = util::httpGetString(urlIntra, "[exch-chart]");
std::string bDaily = util::httpGetString(urlDaily, "[exch-chart]");
auto intra = data::parseExchangeCandles(identifier, bIntra);
auto daily = data::parseExchangeCandles(identifier, bDaily);
return [this, key, intra = std::move(intra), daily = std::move(daily)]() mutable {
exchange_chart_fetch_in_flight_ = false;
if (!daily.empty() || !intra.empty()) {
state_.market.exchange_chart_intraday = std::move(intra);
state_.market.exchange_chart_daily = std::move(daily);
state_.market.exchange_chart_active = true;
exchange_chart_key_ = key;
exchange_chart_last_fetch_ = std::chrono::steady_clock::now();
} else {
// Both ranges empty -> the venue's API failed/changed; keep the aggregate.
state_.market.exchange_chart_active = false;
exchange_chart_key_.clear();
}
};
});
}
void App::refreshMarketChart()

100
src/data/exchange_candles.h Normal file
View File

@@ -0,0 +1,100 @@
#pragma once
// Per-exchange OHLC candle adapters. CoinGecko tells us WHICH exchanges list DRGX (via the ticker
// `market.identifier`, e.g. "ourbit" / "nonkyc_io") but NOT their API — this is the hand-maintained
// mapping from that identifier to each venue's public candle endpoint, so the market chart can show the
// SELECTED exchange's real price history instead of CoinGecko's cross-exchange aggregate.
//
// Header-only + pure (no I/O — the actual HTTP fetch happens in app_network.cpp via util::httpGetString)
// so the URL builder + parsers are unit-tested against real captured responses. An unmapped exchange
// returns an empty URL, so the caller falls back to the CoinGecko aggregate and nothing regresses.
#include <algorithm>
#include <ctime>
#include <string>
#include <utility>
#include <vector>
#include <nlohmann/json.hpp>
namespace dragonx {
namespace data {
using PricePoint = std::pair<std::time_t, double>; // (epoch SECONDS, close price)
enum class CandleRange {
Intraday, // ~2 days of 5-minute candles — backs the Live / 1H / 1D views
Daily, // ~1 year of daily candles — backs the 1W / 1M views
};
// True when we have a candle adapter for this CoinGecko exchange identifier.
inline bool hasExchangeCandleAdapter(const std::string& identifier) {
return identifier == "ourbit" || identifier == "nonkyc_io";
}
// Build the venue's candle URL for a pair + range. Returns "" when the exchange isn't mapped.
// `now` is epoch seconds, passed in (no hidden clock reads) so URLs are deterministic in tests.
inline std::string buildExchangeCandleUrl(const std::string& identifier, const std::string& base,
const std::string& quote, CandleRange range, std::time_t now) {
if (identifier == "ourbit") {
// Ourbit = MEXC-style /api/v3/klines: symbol=BASEQUOTE, interval=5m|1d, limit.
const char* iv = (range == CandleRange::Intraday) ? "5m" : "1d";
const int limit = (range == CandleRange::Intraday) ? 576 : 365;
return "https://api.ourbit.com/api/v3/klines?symbol=" + base + quote +
"&interval=" + iv + "&limit=" + std::to_string(limit);
}
if (identifier == "nonkyc_io") {
// NonKYC = TradingView-UDF candles: symbol=BASE_QUOTE, resolution in minutes, from/to seconds.
const char* res = (range == CandleRange::Intraday) ? "5" : "1440";
const std::time_t span = (range == CandleRange::Intraday) ? (std::time_t)2 * 24 * 3600
: (std::time_t)365 * 24 * 3600;
return "https://api.nonkyc.io/api/v2/market/candles?symbol=" + base + "_" + quote +
"&resolution=" + res + "&from=" + std::to_string(now - span) + "&to=" + std::to_string(now);
}
return "";
}
namespace detail {
// Read a JSON value that may be a number or a numeric string (Ourbit sends prices as strings).
inline double jnum(const nlohmann::json& v) {
if (v.is_number()) return v.get<double>();
if (v.is_string()) { try { return std::stod(v.get<std::string>()); } catch (...) { return 0.0; } }
return 0.0;
}
} // namespace detail
// Parse the venue's candle response into ascending (epoch-seconds, close) points. Empty on failure.
inline std::vector<PricePoint> parseExchangeCandles(const std::string& identifier, const std::string& body) {
std::vector<PricePoint> out;
if (body.empty()) return out;
try {
const nlohmann::json j = nlohmann::json::parse(body);
if (identifier == "ourbit") {
// [[openTime_ms, open, high, low, close, volume, closeTime, quoteVol], ...]
if (!j.is_array()) return out;
out.reserve(j.size());
for (const auto& k : j) {
if (!k.is_array() || k.size() < 5) continue;
const std::time_t t = (std::time_t)(detail::jnum(k[0]) / 1000.0);
const double close = detail::jnum(k[4]);
if (t > 0 && close > 0) out.emplace_back(t, close);
}
} else if (identifier == "nonkyc_io") {
// {"bars":[{"time":ms,"open":..,"high":..,"low":..,"close":..,"volume":..}, ...]}
if (!j.contains("bars") || !j["bars"].is_array()) return out;
out.reserve(j["bars"].size());
for (const auto& b : j["bars"]) {
if (!b.is_object() || !b.contains("time") || !b.contains("close")) continue;
const std::time_t t = (std::time_t)(detail::jnum(b["time"]) / 1000.0);
const double close = detail::jnum(b["close"]);
if (t > 0 && close > 0) out.emplace_back(t, close);
}
}
} catch (...) {
return {};
}
std::sort(out.begin(), out.end(),
[](const PricePoint& a, const PricePoint& b) { return a.first < b.first; });
return out;
}
} // namespace data
} // namespace dragonx

View File

@@ -22,14 +22,14 @@ const std::vector<ExchangeInfo>& getExchangeRegistry()
"Nonkyc.io",
"https://nonkyc.io",
{
{"DRGX", "USDT", "DRGX/USDT", "https://nonkyc.io/market/DRGX_USDT"},
{"DRGX", "USDT", "DRGX/USDT", "https://nonkyc.io/market/DRGX_USDT", "nonkyc_io"},
}
},
{
"OurBit",
"https://www.ourbit.com",
{
{"DRGX", "USDT", "DRGX/USDT", "https://www.ourbit.com/exchange/DRGX_USDT"},
{"DRGX", "USDT", "DRGX/USDT", "https://www.ourbit.com/exchange/DRGX_USDT", "ourbit"},
}
},
};
@@ -59,11 +59,16 @@ std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& body)
if (!t.is_object()) continue;
const std::string base = t.value("base", std::string{});
const std::string target = t.value("target", std::string{});
std::string market;
if (t.contains("market") && t["market"].is_object())
market = t["market"].value("name", std::string{});
std::string market, identifier;
if (t.contains("market") && t["market"].is_object()) {
market = t["market"].value("name", std::string{});
identifier = t["market"].value("identifier", std::string{}); // keys the per-exchange candle adapter
}
const std::string tradeUrl = t.value("trade_url", std::string{});
if (base.empty() || target.empty() || market.empty()) continue;
double lastUsd = 0.0; // this venue's current USD price — differs per exchange
if (t.contains("converted_last") && t["converted_last"].is_object())
lastUsd = t["converted_last"].value("usd", 0.0);
auto it = byName.find(market);
if (it == byName.end()) {
@@ -71,7 +76,7 @@ std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& body)
exchanges.push_back(ExchangeInfo{market, originOf(tradeUrl), {}});
it = byName.find(market);
}
ExchangePair pair{base, target, base + "/" + target, tradeUrl};
ExchangePair pair{base, target, base + "/" + target, tradeUrl, identifier, lastUsd};
// Skip duplicate pairs on the same exchange.
bool dup = false;
for (const auto& p : exchanges[it->second].pairs)

View File

@@ -18,6 +18,8 @@ struct ExchangePair {
std::string quote; ///< e.g. "BTC"
std::string displayName; ///< e.g. "DRGX/BTC"
std::string tradeUrl; ///< Link to the exchange pair page
std::string identifier; ///< CoinGecko exchange id (e.g. "ourbit", "nonkyc_io") — keys the candle adapter
double lastUsd = 0.0; ///< This venue's current price in USD (CoinGecko converted_last.usd); 0 if unknown
};
/**

View File

@@ -81,11 +81,15 @@ inline std::vector<std::pair<std::time_t, double>> chartSeries(const MarketInfo&
for (const auto& s : src) if (s.first >= cutoff) out.push_back(s);
return out;
};
// Draw the SELECTED exchange's own candles when active (data/exchange_candles.h), else the CoinGecko
// cross-exchange aggregate. Only the main chart switches source; portfolio sparklines stay aggregate.
const auto& intraday = m.exchange_chart_active ? m.exchange_chart_intraday : m.price_chart_intraday;
const auto& daily = m.exchange_chart_active ? m.exchange_chart_daily : m.price_chart_daily;
switch (interval) {
case 1: { auto v = lastWindow(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } // 1H
case 2: { auto v = lastWindow(m.price_chart_intraday, kDay); if (v.size() >= 2) return v; break; } // 1D
case 3: { auto v = lastWindow(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W
case 4: { auto v = lastWindow(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M
case 1: { auto v = lastWindow(intraday, 3600); if (v.size() >= 2) return v; break; } // 1H
case 2: { auto v = lastWindow(intraday, kDay); if (v.size() >= 2) return v; break; } // 1D
case 3: { auto v = lastWindow(daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W
case 4: { auto v = lastWindow(daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M
default: break;
}
std::vector<std::pair<std::time_t, double>> out;

View File

@@ -175,6 +175,14 @@ struct MarketInfo {
std::chrono::steady_clock::time_point chart_last_fetch_time{};
bool chart_loaded = false;
// Per-EXCHANGE candle series for the SELECTED pair, fetched from that venue's own API (see
// data/exchange_candles.h). When exchange_chart_active is true the Market chart draws these instead
// of the CoinGecko cross-exchange aggregate above; it's set false while switching pairs or when the
// selected venue has no adapter / its fetch failed, so the chart gracefully falls back to aggregate.
std::vector<std::pair<std::time_t, double>> exchange_chart_intraday;
std::vector<std::pair<std::time_t, double>> exchange_chart_daily;
bool exchange_chart_active = false;
// Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab
// falls back to data::getExchangeRegistry() while empty).
std::vector<data::ExchangeInfo> exchanges;

View File

@@ -1240,10 +1240,17 @@ static void mktDrawPriceHero(const MktCtx& cx)
float cx0 = cardMin.x + Layout::spacingLg();
float cy = cardMin.y + Layout::spacingLg();
if (market.price_usd > 0) {
// Prefer the SELECTED exchange's own current price (it differs per venue — Ourbit vs NonKYC can be
// ~7% apart); fall back to the CoinGecko cross-exchange aggregate when the venue price is unknown.
double heroPrice = market.price_usd;
if (!currentExchange.pairs.empty() && s_mkt.pairIdx < (int)currentExchange.pairs.size()
&& currentExchange.pairs[s_mkt.pairIdx].lastUsd > 0.0)
heroPrice = currentExchange.pairs[s_mkt.pairIdx].lastUsd;
if (heroPrice > 0) {
// ---- HERO PRICE (large, prominent) ----
ImFont* h3 = Type().h3();
std::string priceStr = FormatPrice(market.price_usd);
std::string priceStr = FormatPrice(heroPrice);
ImU32 priceCol = Success();
DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx0, cy), priceCol, priceStr.c_str());