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

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