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