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

View File

@@ -1819,13 +1819,20 @@ void App::refreshExchangeChart()
// 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 {
auto ohlcIntra = data::parseExchangeOHLC(identifier, bIntra);
auto ohlcDaily = data::parseExchangeOHLC(identifier, bDaily);
return [this, key, ohlcIntra = std::move(ohlcIntra), ohlcDaily = std::move(ohlcDaily)]() 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);
if (!ohlcDaily.empty() || !ohlcIntra.empty()) {
// Derive close series (line + change%) from the OHLC (candles).
std::vector<std::pair<std::time_t, double>> closeIntra, closeDaily;
closeIntra.reserve(ohlcIntra.size()); closeDaily.reserve(ohlcDaily.size());
for (const auto& c : ohlcIntra) closeIntra.emplace_back(c.time, c.close);
for (const auto& c : ohlcDaily) closeDaily.emplace_back(c.time, c.close);
state_.market.exchange_chart_intraday = std::move(closeIntra);
state_.market.exchange_chart_daily = std::move(closeDaily);
state_.market.exchange_ohlc_intraday = std::move(ohlcIntra);
state_.market.exchange_ohlc_daily = std::move(ohlcDaily);
state_.market.exchange_chart_active = true;
exchange_chart_key_ = key;
exchange_chart_last_fetch_ = std::chrono::steady_clock::now();