diff --git a/src/app_network.cpp b/src/app_network.cpp index ea02218..9643bf1 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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> 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(); diff --git a/src/data/candle.h b/src/data/candle.h new file mode 100644 index 0000000..de8f1bb --- /dev/null +++ b/src/data/candle.h @@ -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 +#include + +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 bucketOHLC(const std::vector& src, long windowSec) { + std::vector 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 diff --git a/src/data/exchange_candles.h b/src/data/exchange_candles.h index 8fa2ad9..ca0af04 100644 --- a/src/data/exchange_candles.h +++ b/src/data/exchange_candles.h @@ -15,6 +15,8 @@ #include +#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 parseExchangeCandles(const std::string& identifier, const std::string& body) { - std::vector out; +// Parse the venue's candle response into ascending OHLC candles. Empty on failure. +inline std::vector parseExchangeOHLC(const std::string& identifier, const std::string& body) { + std::vector out; if (body.empty()) return out; try { const nlohmann::json j = nlohmann::json::parse(body); @@ -73,9 +75,13 @@ inline std::vector 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 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 parseExchangeCandles(const std::string& identifier, const std::string& body) { + std::vector out; + for (const auto& c : parseExchangeOHLC(identifier, body)) out.emplace_back(c.time, c.close); return out; } diff --git a/src/data/market_series.h b/src/data/market_series.h index 3d0d42c..2a0ae3e 100644 --- a/src/data/market_series.h +++ b/src/data/market_series.h @@ -99,5 +99,28 @@ inline std::vector> 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 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& src, long rangeSec) { + std::vector 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 diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 3dfacea..1fefe45 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -12,6 +12,7 @@ #include #include "exchange_info.h" +#include "candle.h" namespace dragonx { @@ -182,6 +183,10 @@ struct MarketInfo { std::vector> exchange_chart_intraday; std::vector> 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 exchange_ohlc_intraday; + std::vector exchange_ohlc_daily; // Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab // falls back to data::getExchangeRegistry() while empty). diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index abb4329..1349bcd 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -1202,6 +1202,7 @@ struct MktCtx { const data::ExchangeInfo* currentExchange; float chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH; const std::vector* chartTimes; + const std::vector* 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 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); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 462c145..3ddef9b 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(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(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 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(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(2)); // 1M daily + EXPECT_TRUE(chartCandles(cm, 0, now).empty()); // Live -> line } // Regression: the Market-tab portfolio (and other consumers) read the combined