The exchange candle APIs return full OHLC but we only kept the close (a line). Now the per-exchange chart draws real candlesticks; the CoinGecko aggregate stays a line (it's close-only). - Adapter keeps OHLC: parseExchangeOHLC() returns open/high/low/close candles (parseExchangeCandles is now a close-only wrapper over it). New data/candle.h holds the dependency-free Candle + bucketOHLC (5-min -> hourly for the 1D view). - Model stores exchange_ohlc_intraday/daily alongside the close series; refreshExchangeChart populates both. market_series::chartCandles() returns the bucketed OHLC for the range, empty unless the per-exchange series is active. - The chart renders wick (low..high) + body (open..close), green up / red down, with a low..high y-range; the line-only bits (fill, hi/lo labels, hover tooltip) are gated off for candles. Falls back to the line for the aggregate / Live view. Verified: OHLC parsers + bucketOHLC + chartCandles unit-tested; a forced-state render shows correct candlesticks; the aggregate still draws a clean line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
5.6 KiB
C++
118 lines
5.6 KiB
C++
#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>
|
|
|
|
#include "candle.h"
|
|
|
|
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 OHLC candles. Empty on failure.
|
|
inline std::vector<Candle> parseExchangeOHLC(const std::string& identifier, const std::string& body) {
|
|
std::vector<Candle> 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;
|
|
Candle c;
|
|
c.time = (std::time_t)(detail::jnum(k[0]) / 1000.0);
|
|
c.open = detail::jnum(k[1]);
|
|
c.high = detail::jnum(k[2]);
|
|
c.low = detail::jnum(k[3]);
|
|
c.close = detail::jnum(k[4]);
|
|
if (c.time > 0 && c.close > 0) out.push_back(c);
|
|
}
|
|
} 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;
|
|
Candle c;
|
|
c.time = (std::time_t)(detail::jnum(b["time"]) / 1000.0);
|
|
c.close = detail::jnum(b["close"]);
|
|
c.open = b.contains("open") ? detail::jnum(b["open"]) : c.close;
|
|
c.high = b.contains("high") ? detail::jnum(b["high"]) : c.close;
|
|
c.low = b.contains("low") ? detail::jnum(b["low"]) : c.close;
|
|
if (c.time > 0 && c.close > 0) out.push_back(c);
|
|
}
|
|
}
|
|
} catch (...) {
|
|
return {};
|
|
}
|
|
std::sort(out.begin(), out.end(), [](const Candle& a, const Candle& b) { return a.time < b.time; });
|
|
return out;
|
|
}
|
|
|
|
// Close-only convenience over parseExchangeOHLC (backs the line chart + change-% fallback). bucketOHLC
|
|
// for candlestick resampling lives in candle.h (dependency-free, reused by market_series.h).
|
|
inline std::vector<PricePoint> parseExchangeCandles(const std::string& identifier, const std::string& body) {
|
|
std::vector<PricePoint> out;
|
|
for (const auto& c : parseExchangeOHLC(identifier, body)) out.emplace_back(c.time, c.close);
|
|
return out;
|
|
}
|
|
|
|
} // namespace data
|
|
} // namespace dragonx
|