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
parent 326192e75e
commit 40e8128a30
7 changed files with 205 additions and 40 deletions

44
src/data/candle.h Normal file
View File

@@ -0,0 +1,44 @@
#pragma once
// A single OHLC candle — the shared shape for per-exchange candlestick charts. Kept in its own tiny,
// dependency-free header so both the parser (data/exchange_candles.h, which pulls in nlohmann/json)
// and the model (data/wallet_state.h, included widely) can use it without dragging json everywhere.
#include <ctime>
#include <vector>
namespace dragonx {
namespace data {
struct Candle {
std::time_t time = 0; // epoch SECONDS (the candle's open/bucket time)
double open = 0.0;
double high = 0.0;
double low = 0.0;
double close = 0.0;
};
// Aggregate fine candles into fixed time buckets (e.g. 5-min -> hourly for the 1D view): open = first
// open, high = max high, low = min low, close = last close. Input must be ascending by time.
inline std::vector<Candle> bucketOHLC(const std::vector<Candle>& src, long windowSec) {
std::vector<Candle> out;
if (src.empty() || windowSec <= 0) return out;
long curBucket = -1;
Candle cur;
for (const auto& c : src) {
const long b = (long)(c.time / windowSec);
if (b != curBucket) {
if (curBucket >= 0) out.push_back(cur);
curBucket = b;
cur = c;
cur.time = (std::time_t)(b * windowSec);
} else {
if (c.high > cur.high) cur.high = c.high;
if (c.low < cur.low) cur.low = c.low;
cur.close = c.close;
}
}
if (curBucket >= 0) out.push_back(cur);
return out;
}
} // namespace data
} // namespace dragonx

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;
}

View File

@@ -99,5 +99,28 @@ inline std::vector<std::pair<std::time_t, double>> chartSeries(const MarketInfo&
return out;
}
// OHLC candles for the main chart at the selected RANGE — only when the per-exchange series is active
// (the CoinGecko aggregate is close-only, so this returns empty and the chart draws a line). The 1D
// view buckets the 5-minute intraday to hourly so it isn't ~288 hair-thin candles; other ranges use
// the raw candles. Empty for the Live range (uses the in-session line).
inline std::vector<Candle> chartCandles(const MarketInfo& m, int interval, std::time_t now)
{
if (!m.exchange_chart_active) return {};
const long kDay = 86400;
auto window = [now](const std::vector<Candle>& src, long rangeSec) {
std::vector<Candle> out;
const std::time_t cutoff = now - (std::time_t)rangeSec;
for (const auto& c : src) if (c.time >= cutoff) out.push_back(c);
return out;
};
switch (interval) {
case 1: return window(m.exchange_ohlc_intraday, 3600); // 1H: 5-min candles (~12)
case 2: return bucketOHLC(window(m.exchange_ohlc_intraday, kDay), 3600); // 1D: hourly candles (~24)
case 3: return window(m.exchange_ohlc_daily, 7 * kDay); // 1W: daily (~7)
case 4: return window(m.exchange_ohlc_daily, 30 * kDay); // 1M: daily (~30)
default: return {}; // Live -> line
}
}
} // namespace data
} // namespace dragonx

View File

@@ -12,6 +12,7 @@
#include <utility>
#include "exchange_info.h"
#include "candle.h"
namespace dragonx {
@@ -182,6 +183,10 @@ struct MarketInfo {
std::vector<std::pair<std::time_t, double>> exchange_chart_intraday;
std::vector<std::pair<std::time_t, double>> exchange_chart_daily;
bool exchange_chart_active = false;
// Full OHLC for the same per-exchange candles, backing the candlestick rendering (the aggregate
// CoinGecko series is close-only, so it stays a line). Parallel to exchange_chart_* above.
std::vector<data::Candle> exchange_ohlc_intraday;
std::vector<data::Candle> exchange_ohlc_daily;
// Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab
// falls back to data::getExchangeRegistry() while empty).