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

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