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

@@ -48,6 +48,7 @@
#include "data/wallet_state.h"
#include "data/portfolio.h"
#include "data/market_series.h"
#include "data/exchange_candles.h"
#include "fake_lite_backend.h"
#include <chrono>
@@ -2903,6 +2904,66 @@ void testMarketSeries()
EXPECT_NEAR(live.back().second, 3.0, 1e-9);
}
// Per-exchange candle adapters (data/exchange_candles.h) — the URL builder + the Ourbit (array) /
// NonKYC (UDF bars) parsers, verified against real captured API responses, plus the chartSeries
// switch that draws the per-exchange series when it's active.
void testExchangeCandles()
{
using dragonx::data::buildExchangeCandleUrl;
using dragonx::data::parseExchangeCandles;
using dragonx::data::hasExchangeCandleAdapter;
using dragonx::data::CandleRange;
// --- URL builder (deterministic given `now`) ---
EXPECT_TRUE(buildExchangeCandleUrl("ourbit", "DRGX", "USDT", CandleRange::Intraday, 1000)
== "https://api.ourbit.com/api/v3/klines?symbol=DRGXUSDT&interval=5m&limit=576");
EXPECT_TRUE(buildExchangeCandleUrl("ourbit", "DRGX", "USDT", CandleRange::Daily, 1000)
== "https://api.ourbit.com/api/v3/klines?symbol=DRGXUSDT&interval=1d&limit=365");
EXPECT_TRUE(buildExchangeCandleUrl("nonkyc_io", "DRGX", "USDT", CandleRange::Daily, 1000000000)
== "https://api.nonkyc.io/api/v2/market/candles?symbol=DRGX_USDT&resolution=1440&from=968464000&to=1000000000");
EXPECT_TRUE(buildExchangeCandleUrl("binance", "DRGX", "USDT", CandleRange::Daily, 1000).empty());
EXPECT_TRUE(hasExchangeCandleAdapter("ourbit") && hasExchangeCandleAdapter("nonkyc_io"));
EXPECT_TRUE(!hasExchangeCandleAdapter("kraken"));
// --- Ourbit parse: array of arrays, close at index 4, prices as strings, time in ms ---
auto o = parseExchangeCandles("ourbit",
R"([[1783728000000,"0.01298","0.01406","0.01286","0.01395","1203855.69",1783814400000,"16132.16809"],)"
R"([1783814400000,"0.01395","0.01418","0.01364","0.01371","584276.8",1783900800000,"8126.07742"]])");
EXPECT_EQ(o.size(), static_cast<size_t>(2));
EXPECT_NEAR(o[0].second, 0.01395, 1e-9); // close = idx 4
EXPECT_EQ(static_cast<long>(o[0].first), 1783728000L); // ms -> seconds
EXPECT_TRUE(o[0].first < o[1].first); // ascending
// --- NonKYC parse: {"bars":[{time,close,...}]}, close numeric, time in ms ---
auto n = parseExchangeCandles("nonkyc_io",
R"({"bars":[{"time":1781308800000,"close":0.031354,"open":0.032559,"high":0.034999,"low":0.02804,"volume":23550.357},)"
R"({"time":1781395200000,"close":0.02555,"open":0.031091,"high":0.03296,"low":0.025035,"volume":17657.726}]})");
EXPECT_EQ(n.size(), static_cast<size_t>(2));
EXPECT_NEAR(n[0].second, 0.031354, 1e-9);
EXPECT_EQ(static_cast<long>(n[0].first), 1781308800L);
// --- robustness: empty / garbage / no-data / unmapped -> empty ---
EXPECT_TRUE(parseExchangeCandles("ourbit", "").empty());
EXPECT_TRUE(parseExchangeCandles("ourbit", "not json").empty());
EXPECT_TRUE(parseExchangeCandles("nonkyc_io", R"({"bars":[],"meta":{"noData":true}})").empty());
EXPECT_TRUE(parseExchangeCandles("binance", "[[1,2,3,4,5]]").empty());
// --- chartSeries prefers the per-exchange series when active, else the aggregate ---
using dragonx::MarketInfo;
using dragonx::data::chartSeries;
std::time_t now = 2000000000;
MarketInfo m;
// Aggregate daily (two points in the last 30d) vs a DISTINCT exchange daily series.
m.price_chart_daily = { {now - 10 * 86400, 1.0}, {now - 1 * 86400, 1.1} };
m.exchange_chart_daily = { {now - 10 * 86400, 5.0}, {now - 1 * 86400, 5.5} };
m.exchange_chart_active = false;
auto agg = chartSeries(m, 4, now); // 1M
EXPECT_TRUE(!agg.empty() && agg.back().second < 2.0); // aggregate values
m.exchange_chart_active = true;
auto exch = chartSeries(m, 4, now);
EXPECT_TRUE(!exch.empty() && exch.back().second > 4.0); // exchange values
}
// Regression: the Market-tab portfolio (and other consumers) read the combined
// WalletState::addresses view, which the full-node refresh path builds from the
// authoritative z/t lists via rebuildAddressList(). Before the fix that rebuild
@@ -6281,6 +6342,7 @@ int main()
testConsoleModel();
testPortfolioHelpers();
testMarketSeries();
testExchangeCandles();
testWalletStateAddressListRebuild();
testConsoleFoldSpans();
testConsoleScrollController();