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:
@@ -1202,6 +1202,7 @@ struct MktCtx {
|
||||
const data::ExchangeInfo* currentExchange;
|
||||
float chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH;
|
||||
const std::vector<std::time_t>* chartTimes;
|
||||
const std::vector<data::Candle>* chartCandles; // per-exchange OHLC (empty -> line chart)
|
||||
std::time_t nowSec;
|
||||
bool chartUp;
|
||||
double periodChangePct;
|
||||
@@ -1463,10 +1464,18 @@ static void mktDrawPriceChart(const MktCtx& cx)
|
||||
float plotW = plotRight - plotLeft;
|
||||
float plotH = plotBottom - plotTop;
|
||||
|
||||
if (s_mkt.history.size() >= 2) {
|
||||
// Compute Y range with padding
|
||||
double yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end());
|
||||
double yMax = *std::max_element(s_mkt.history.begin(), s_mkt.history.end());
|
||||
const auto& candles = *cx.chartCandles;
|
||||
const bool hasCandles = candles.size() >= 2; // per-exchange OHLC -> candlesticks, else a line
|
||||
if (hasCandles || s_mkt.history.size() >= 2) {
|
||||
// Compute Y range with padding — candles span low..high, the line spans close..close.
|
||||
double yMin, yMax;
|
||||
if (hasCandles) {
|
||||
yMin = candles[0].low; yMax = candles[0].high;
|
||||
for (const auto& c : candles) { if (c.low < yMin) yMin = c.low; if (c.high > yMax) yMax = c.high; }
|
||||
} else {
|
||||
yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end());
|
||||
yMax = *std::max_element(s_mkt.history.begin(), s_mkt.history.end());
|
||||
}
|
||||
if (yMax <= yMin) { yMax = yMin + 1e-8; }
|
||||
double yRange = yMax - yMin;
|
||||
double yPadding = yRange * 0.12;
|
||||
@@ -1499,24 +1508,41 @@ static void mktDrawPriceChart(const MktCtx& cx)
|
||||
ImU32 lineCol = WithAlpha(dirCol, 220);
|
||||
ImU32 dotCol = dirCol;
|
||||
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
|
||||
float x = plotLeft + t * plotW;
|
||||
float y = plotBottom - (float)((s_mkt.history[i] - yMin) / (yMax - yMin)) * plotH;
|
||||
points[i] = ImVec2(x, y);
|
||||
auto yOf = [&](double v){ return plotBottom - (float)((v - yMin) / (yMax - yMin)) * plotH; };
|
||||
if (hasCandles) {
|
||||
// Candlesticks: a thin wick (low..high) + a body (open..close), green up / red down.
|
||||
const int cn = (int)candles.size();
|
||||
const float slotW = plotW / (float)cn;
|
||||
const float bodyW = std::max(1.0f, slotW * 0.62f);
|
||||
for (int i = 0; i < cn; i++) {
|
||||
const auto& c = candles[i];
|
||||
const float xc = plotLeft + ((float)i + 0.5f) * slotW;
|
||||
const ImU32 col = (c.close >= c.open) ? Success() : Error();
|
||||
dl->AddLine(ImVec2(xc, yOf(c.high)), ImVec2(xc, yOf(c.low)), WithAlpha(col, 210),
|
||||
std::max(1.0f, mktDp));
|
||||
float bT = yOf(std::max(c.open, c.close)); // higher price -> smaller y -> body top
|
||||
float bB = yOf(std::min(c.open, c.close));
|
||||
if (bB - bT < 1.5f * mktDp) bB = bT + 1.5f * mktDp; // doji -> keep a visible sliver
|
||||
dl->AddRectFilled(ImVec2(xc - bodyW * 0.5f, bT), ImVec2(xc + bodyW * 0.5f, bB),
|
||||
WithAlpha(col, 230), 1.0f);
|
||||
}
|
||||
} else {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
|
||||
points[i] = ImVec2(plotLeft + t * plotW, yOf(s_mkt.history[i]));
|
||||
}
|
||||
// Flat translucent area fill under the curve (matches the portfolio group sparklines).
|
||||
for (size_t i = 0; i < n; i++) dl->PathLineTo(points[i]);
|
||||
dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
|
||||
dl->PathLineTo(ImVec2(points[0].x, plotBottom));
|
||||
dl->PathFillConcave(WithAlpha(dirCol, 28));
|
||||
// Line (no per-point dots — a clean curve).
|
||||
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
|
||||
}
|
||||
|
||||
// Flat translucent area fill under the curve (matches the portfolio group sparklines).
|
||||
for (size_t i = 0; i < n; i++) dl->PathLineTo(points[i]);
|
||||
dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
|
||||
dl->PathLineTo(ImVec2(points[0].x, plotBottom));
|
||||
dl->PathFillConcave(WithAlpha(dirCol, 28));
|
||||
|
||||
// Line (no per-point dots — a clean curve).
|
||||
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
|
||||
|
||||
// High/low price labels at the displayed range's extremes (no dot markers).
|
||||
if (n >= 3) {
|
||||
// High/low price labels at the displayed range's extremes (no dot markers). Line only — the
|
||||
// candlestick wicks already show each period's high/low.
|
||||
if (!hasCandles && n >= 3) {
|
||||
size_t hiIdx = (size_t)(std::max_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
|
||||
size_t loIdx = (size_t)(std::min_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
|
||||
auto markExtreme = [&](size_t idx, bool high) {
|
||||
@@ -1558,9 +1584,9 @@ static void mktDrawPriceChart(const MktCtx& cx)
|
||||
}
|
||||
}
|
||||
|
||||
// Hover crosshair + tooltip
|
||||
// Hover crosshair + tooltip (line only — it indexes the close-series `points`).
|
||||
ImVec2 mousePos = ImGui::GetIO().MousePos;
|
||||
if (mousePos.x >= plotLeft && mousePos.x <= plotRight &&
|
||||
if (!hasCandles && mousePos.x >= plotLeft && mousePos.x <= plotRight &&
|
||||
mousePos.y >= plotTop && mousePos.y <= plotBottom + labelPadBottom)
|
||||
{
|
||||
float mx = mousePos.x - plotLeft;
|
||||
@@ -1927,6 +1953,8 @@ void RenderMarketTab(App* app)
|
||||
s_mkt.history.push_back(pr.second);
|
||||
chartTimes.push_back(pr.first);
|
||||
}
|
||||
// OHLC candles for the selected range when a per-exchange series is active (empty -> line chart).
|
||||
std::vector<data::Candle> ohlcCandles = data::chartCandles(market, s_mkt.chartInterval, nowSec);
|
||||
// Change over the displayed period (Live falls back to the market's 24h figure).
|
||||
double periodChangePct = market.change_24h;
|
||||
if (s_mkt.chartInterval != 0 && s_mkt.history.size() >= 2 && s_mkt.history.front() > 0.0)
|
||||
@@ -1948,7 +1976,7 @@ void RenderMarketTab(App* app)
|
||||
MktCtx mktc{
|
||||
app, ¤tExchange,
|
||||
chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH,
|
||||
&chartTimes, nowSec, chartUp, periodChangePct, periodSuffix
|
||||
&chartTimes, &ohlcCandles, nowSec, chartUp, periodChangePct, periodSuffix
|
||||
};
|
||||
mktDrawPriceHero(mktc);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user