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:
@@ -2962,6 +2962,47 @@ void testExchangeCandles()
|
||||
m.exchange_chart_active = true;
|
||||
auto exch = chartSeries(m, 4, now);
|
||||
EXPECT_TRUE(!exch.empty() && exch.back().second > 4.0); // exchange values
|
||||
|
||||
// --- OHLC parse: full open/high/low/close, both formats ---
|
||||
using dragonx::data::parseExchangeOHLC;
|
||||
auto oc = parseExchangeOHLC("ourbit",
|
||||
R"([[1783728000000,"0.01298","0.01406","0.01286","0.01395","1203855.69",1783814400000,"16132.16809"]])");
|
||||
EXPECT_EQ(oc.size(), static_cast<size_t>(1));
|
||||
EXPECT_NEAR(oc[0].open, 0.01298, 1e-9);
|
||||
EXPECT_NEAR(oc[0].high, 0.01406, 1e-9);
|
||||
EXPECT_NEAR(oc[0].low, 0.01286, 1e-9);
|
||||
EXPECT_NEAR(oc[0].close, 0.01395, 1e-9);
|
||||
auto nc = parseExchangeOHLC("nonkyc_io",
|
||||
R"({"bars":[{"time":1781308800000,"close":0.031354,"open":0.032559,"high":0.034999,"low":0.02804,"volume":1}]})");
|
||||
EXPECT_EQ(nc.size(), static_cast<size_t>(1));
|
||||
EXPECT_NEAR(nc[0].high, 0.034999, 1e-9);
|
||||
EXPECT_NEAR(nc[0].low, 0.02804, 1e-9);
|
||||
|
||||
// --- bucketOHLC: two 5-min candles in the same hour -> one hourly candle (o=first, h=max, l=min, c=last) ---
|
||||
using dragonx::data::bucketOHLC;
|
||||
using dragonx::data::Candle;
|
||||
std::vector<Candle> fine = {
|
||||
{3600, 10.0, 12.0, 9.0, 11.0}, // hour bucket 1
|
||||
{3900, 11.0, 15.0, 8.0, 14.0}, // same hour bucket 1
|
||||
{7200, 20.0, 21.0, 19.0, 20.5}, // hour bucket 2
|
||||
};
|
||||
auto hourly = bucketOHLC(fine, 3600);
|
||||
EXPECT_EQ(hourly.size(), static_cast<size_t>(2));
|
||||
EXPECT_NEAR(hourly[0].open, 10.0, 1e-9); // first open
|
||||
EXPECT_NEAR(hourly[0].high, 15.0, 1e-9); // max high
|
||||
EXPECT_NEAR(hourly[0].low, 8.0, 1e-9); // min low
|
||||
EXPECT_NEAR(hourly[0].close, 14.0, 1e-9); // last close
|
||||
EXPECT_TRUE(bucketOHLC({}, 3600).empty());
|
||||
|
||||
// --- chartCandles: candles only when the per-exchange series is active ---
|
||||
using dragonx::data::chartCandles;
|
||||
MarketInfo cm;
|
||||
cm.exchange_ohlc_daily = { {now - 3 * 86400, 1, 1.2, 0.9, 1.1}, {now - 1 * 86400, 1.1, 1.3, 1.0, 1.25} };
|
||||
cm.exchange_chart_active = false;
|
||||
EXPECT_TRUE(chartCandles(cm, 4, now).empty()); // inactive -> line (no candles)
|
||||
cm.exchange_chart_active = true;
|
||||
EXPECT_EQ(chartCandles(cm, 4, now).size(), static_cast<size_t>(2)); // 1M daily
|
||||
EXPECT_TRUE(chartCandles(cm, 0, now).empty()); // Live -> line
|
||||
}
|
||||
|
||||
// Regression: the Market-tab portfolio (and other consumers) read the combined
|
||||
|
||||
Reference in New Issue
Block a user