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