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:
@@ -0,0 +1,100 @@
|
||||
#pragma once
|
||||
// Per-exchange OHLC candle adapters. CoinGecko tells us WHICH exchanges list DRGX (via the ticker
|
||||
// `market.identifier`, e.g. "ourbit" / "nonkyc_io") but NOT their API — this is the hand-maintained
|
||||
// mapping from that identifier to each venue's public candle endpoint, so the market chart can show the
|
||||
// SELECTED exchange's real price history instead of CoinGecko's cross-exchange aggregate.
|
||||
//
|
||||
// Header-only + pure (no I/O — the actual HTTP fetch happens in app_network.cpp via util::httpGetString)
|
||||
// so the URL builder + parsers are unit-tested against real captured responses. An unmapped exchange
|
||||
// returns an empty URL, so the caller falls back to the CoinGecko aggregate and nothing regresses.
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
using PricePoint = std::pair<std::time_t, double>; // (epoch SECONDS, close price)
|
||||
|
||||
enum class CandleRange {
|
||||
Intraday, // ~2 days of 5-minute candles — backs the Live / 1H / 1D views
|
||||
Daily, // ~1 year of daily candles — backs the 1W / 1M views
|
||||
};
|
||||
|
||||
// True when we have a candle adapter for this CoinGecko exchange identifier.
|
||||
inline bool hasExchangeCandleAdapter(const std::string& identifier) {
|
||||
return identifier == "ourbit" || identifier == "nonkyc_io";
|
||||
}
|
||||
|
||||
// Build the venue's candle URL for a pair + range. Returns "" when the exchange isn't mapped.
|
||||
// `now` is epoch seconds, passed in (no hidden clock reads) so URLs are deterministic in tests.
|
||||
inline std::string buildExchangeCandleUrl(const std::string& identifier, const std::string& base,
|
||||
const std::string& quote, CandleRange range, std::time_t now) {
|
||||
if (identifier == "ourbit") {
|
||||
// Ourbit = MEXC-style /api/v3/klines: symbol=BASEQUOTE, interval=5m|1d, limit.
|
||||
const char* iv = (range == CandleRange::Intraday) ? "5m" : "1d";
|
||||
const int limit = (range == CandleRange::Intraday) ? 576 : 365;
|
||||
return "https://api.ourbit.com/api/v3/klines?symbol=" + base + quote +
|
||||
"&interval=" + iv + "&limit=" + std::to_string(limit);
|
||||
}
|
||||
if (identifier == "nonkyc_io") {
|
||||
// NonKYC = TradingView-UDF candles: symbol=BASE_QUOTE, resolution in minutes, from/to seconds.
|
||||
const char* res = (range == CandleRange::Intraday) ? "5" : "1440";
|
||||
const std::time_t span = (range == CandleRange::Intraday) ? (std::time_t)2 * 24 * 3600
|
||||
: (std::time_t)365 * 24 * 3600;
|
||||
return "https://api.nonkyc.io/api/v2/market/candles?symbol=" + base + "_" + quote +
|
||||
"&resolution=" + res + "&from=" + std::to_string(now - span) + "&to=" + std::to_string(now);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
// Read a JSON value that may be a number or a numeric string (Ourbit sends prices as strings).
|
||||
inline double jnum(const nlohmann::json& v) {
|
||||
if (v.is_number()) return v.get<double>();
|
||||
if (v.is_string()) { try { return std::stod(v.get<std::string>()); } catch (...) { return 0.0; } }
|
||||
return 0.0;
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
// Parse the venue's candle response into ascending (epoch-seconds, close) points. Empty on failure.
|
||||
inline std::vector<PricePoint> parseExchangeCandles(const std::string& identifier, const std::string& body) {
|
||||
std::vector<PricePoint> out;
|
||||
if (body.empty()) return out;
|
||||
try {
|
||||
const nlohmann::json j = nlohmann::json::parse(body);
|
||||
if (identifier == "ourbit") {
|
||||
// [[openTime_ms, open, high, low, close, volume, closeTime, quoteVol], ...]
|
||||
if (!j.is_array()) return out;
|
||||
out.reserve(j.size());
|
||||
for (const auto& k : j) {
|
||||
if (!k.is_array() || k.size() < 5) continue;
|
||||
const std::time_t t = (std::time_t)(detail::jnum(k[0]) / 1000.0);
|
||||
const double close = detail::jnum(k[4]);
|
||||
if (t > 0 && close > 0) out.emplace_back(t, close);
|
||||
}
|
||||
} else if (identifier == "nonkyc_io") {
|
||||
// {"bars":[{"time":ms,"open":..,"high":..,"low":..,"close":..,"volume":..}, ...]}
|
||||
if (!j.contains("bars") || !j["bars"].is_array()) return out;
|
||||
out.reserve(j["bars"].size());
|
||||
for (const auto& b : j["bars"]) {
|
||||
if (!b.is_object() || !b.contains("time") || !b.contains("close")) continue;
|
||||
const std::time_t t = (std::time_t)(detail::jnum(b["time"]) / 1000.0);
|
||||
const double close = detail::jnum(b["close"]);
|
||||
if (t > 0 && close > 0) out.emplace_back(t, close);
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const PricePoint& a, const PricePoint& b) { return a.first < b.first; });
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user