feat(market): candlestick chart for per-exchange OHLC

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>
This commit is contained in:
2026-07-12 11:16:44 -05:00
co-authored by Claude Opus 4.8
parent 326192e75e
commit 40e8128a30
7 changed files with 205 additions and 40 deletions
+28 -11
View File
@@ -15,6 +15,8 @@
#include <nlohmann/json.hpp>
#include "candle.h"
namespace dragonx {
namespace data {
@@ -61,9 +63,9 @@ inline double jnum(const nlohmann::json& v) {
}
} // 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;
// 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);
@@ -73,9 +75,13 @@ inline std::vector<PricePoint> parseExchangeCandles(const std::string& identifie
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);
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":..}, ...]}
@@ -83,16 +89,27 @@ inline std::vector<PricePoint> parseExchangeCandles(const std::string& identifie
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);
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 PricePoint& a, const PricePoint& b) { return a.first < b.first; });
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;
}