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:
@@ -1819,13 +1819,20 @@ void App::refreshExchangeChart()
|
|||||||
// httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
|
// httpGetString verifies TLS and returns "" on any failure (parses to an empty series).
|
||||||
std::string bIntra = util::httpGetString(urlIntra, "[exch-chart]");
|
std::string bIntra = util::httpGetString(urlIntra, "[exch-chart]");
|
||||||
std::string bDaily = util::httpGetString(urlDaily, "[exch-chart]");
|
std::string bDaily = util::httpGetString(urlDaily, "[exch-chart]");
|
||||||
auto intra = data::parseExchangeCandles(identifier, bIntra);
|
auto ohlcIntra = data::parseExchangeOHLC(identifier, bIntra);
|
||||||
auto daily = data::parseExchangeCandles(identifier, bDaily);
|
auto ohlcDaily = data::parseExchangeOHLC(identifier, bDaily);
|
||||||
return [this, key, intra = std::move(intra), daily = std::move(daily)]() mutable {
|
return [this, key, ohlcIntra = std::move(ohlcIntra), ohlcDaily = std::move(ohlcDaily)]() mutable {
|
||||||
exchange_chart_fetch_in_flight_ = false;
|
exchange_chart_fetch_in_flight_ = false;
|
||||||
if (!daily.empty() || !intra.empty()) {
|
if (!ohlcDaily.empty() || !ohlcIntra.empty()) {
|
||||||
state_.market.exchange_chart_intraday = std::move(intra);
|
// Derive close series (line + change%) from the OHLC (candles).
|
||||||
state_.market.exchange_chart_daily = std::move(daily);
|
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;
|
state_.market.exchange_chart_active = true;
|
||||||
exchange_chart_key_ = key;
|
exchange_chart_key_ = key;
|
||||||
exchange_chart_last_fetch_ = std::chrono::steady_clock::now();
|
exchange_chart_last_fetch_ = std::chrono::steady_clock::now();
|
||||||
|
|||||||
44
src/data/candle.h
Normal file
44
src/data/candle.h
Normal 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
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include "candle.h"
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace data {
|
namespace data {
|
||||||
|
|
||||||
@@ -61,9 +63,9 @@ inline double jnum(const nlohmann::json& v) {
|
|||||||
}
|
}
|
||||||
} // namespace detail
|
} // namespace detail
|
||||||
|
|
||||||
// Parse the venue's candle response into ascending (epoch-seconds, close) points. Empty on failure.
|
// Parse the venue's candle response into ascending OHLC candles. Empty on failure.
|
||||||
inline std::vector<PricePoint> parseExchangeCandles(const std::string& identifier, const std::string& body) {
|
inline std::vector<Candle> parseExchangeOHLC(const std::string& identifier, const std::string& body) {
|
||||||
std::vector<PricePoint> out;
|
std::vector<Candle> out;
|
||||||
if (body.empty()) return out;
|
if (body.empty()) return out;
|
||||||
try {
|
try {
|
||||||
const nlohmann::json j = nlohmann::json::parse(body);
|
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());
|
out.reserve(j.size());
|
||||||
for (const auto& k : j) {
|
for (const auto& k : j) {
|
||||||
if (!k.is_array() || k.size() < 5) continue;
|
if (!k.is_array() || k.size() < 5) continue;
|
||||||
const std::time_t t = (std::time_t)(detail::jnum(k[0]) / 1000.0);
|
Candle c;
|
||||||
const double close = detail::jnum(k[4]);
|
c.time = (std::time_t)(detail::jnum(k[0]) / 1000.0);
|
||||||
if (t > 0 && close > 0) out.emplace_back(t, close);
|
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") {
|
} else if (identifier == "nonkyc_io") {
|
||||||
// {"bars":[{"time":ms,"open":..,"high":..,"low":..,"close":..,"volume":..}, ...]}
|
// {"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());
|
out.reserve(j["bars"].size());
|
||||||
for (const auto& b : j["bars"]) {
|
for (const auto& b : j["bars"]) {
|
||||||
if (!b.is_object() || !b.contains("time") || !b.contains("close")) continue;
|
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);
|
Candle c;
|
||||||
const double close = detail::jnum(b["close"]);
|
c.time = (std::time_t)(detail::jnum(b["time"]) / 1000.0);
|
||||||
if (t > 0 && close > 0) out.emplace_back(t, close);
|
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 (...) {
|
} catch (...) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
std::sort(out.begin(), out.end(),
|
std::sort(out.begin(), out.end(), [](const Candle& a, const Candle& b) { return a.time < b.time; });
|
||||||
[](const PricePoint& a, const PricePoint& b) { return a.first < b.first; });
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,5 +99,28 @@ inline std::vector<std::pair<std::time_t, double>> chartSeries(const MarketInfo&
|
|||||||
return out;
|
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 data
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "exchange_info.h"
|
#include "exchange_info.h"
|
||||||
|
#include "candle.h"
|
||||||
|
|
||||||
namespace dragonx {
|
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_intraday;
|
||||||
std::vector<std::pair<std::time_t, double>> exchange_chart_daily;
|
std::vector<std::pair<std::time_t, double>> exchange_chart_daily;
|
||||||
bool exchange_chart_active = false;
|
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
|
// Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab
|
||||||
// falls back to data::getExchangeRegistry() while empty).
|
// falls back to data::getExchangeRegistry() while empty).
|
||||||
|
|||||||
@@ -1202,6 +1202,7 @@ struct MktCtx {
|
|||||||
const data::ExchangeInfo* currentExchange;
|
const data::ExchangeInfo* currentExchange;
|
||||||
float chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH;
|
float chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH;
|
||||||
const std::vector<std::time_t>* chartTimes;
|
const std::vector<std::time_t>* chartTimes;
|
||||||
|
const std::vector<data::Candle>* chartCandles; // per-exchange OHLC (empty -> line chart)
|
||||||
std::time_t nowSec;
|
std::time_t nowSec;
|
||||||
bool chartUp;
|
bool chartUp;
|
||||||
double periodChangePct;
|
double periodChangePct;
|
||||||
@@ -1463,10 +1464,18 @@ static void mktDrawPriceChart(const MktCtx& cx)
|
|||||||
float plotW = plotRight - plotLeft;
|
float plotW = plotRight - plotLeft;
|
||||||
float plotH = plotBottom - plotTop;
|
float plotH = plotBottom - plotTop;
|
||||||
|
|
||||||
if (s_mkt.history.size() >= 2) {
|
const auto& candles = *cx.chartCandles;
|
||||||
// Compute Y range with padding
|
const bool hasCandles = candles.size() >= 2; // per-exchange OHLC -> candlesticks, else a line
|
||||||
double yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end());
|
if (hasCandles || s_mkt.history.size() >= 2) {
|
||||||
double yMax = *std::max_element(s_mkt.history.begin(), s_mkt.history.end());
|
// 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; }
|
if (yMax <= yMin) { yMax = yMin + 1e-8; }
|
||||||
double yRange = yMax - yMin;
|
double yRange = yMax - yMin;
|
||||||
double yPadding = yRange * 0.12;
|
double yPadding = yRange * 0.12;
|
||||||
@@ -1499,24 +1508,41 @@ static void mktDrawPriceChart(const MktCtx& cx)
|
|||||||
ImU32 lineCol = WithAlpha(dirCol, 220);
|
ImU32 lineCol = WithAlpha(dirCol, 220);
|
||||||
ImU32 dotCol = dirCol;
|
ImU32 dotCol = dirCol;
|
||||||
|
|
||||||
for (size_t i = 0; i < n; i++) {
|
auto yOf = [&](double v){ return plotBottom - (float)((v - yMin) / (yMax - yMin)) * plotH; };
|
||||||
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
|
if (hasCandles) {
|
||||||
float x = plotLeft + t * plotW;
|
// Candlesticks: a thin wick (low..high) + a body (open..close), green up / red down.
|
||||||
float y = plotBottom - (float)((s_mkt.history[i] - yMin) / (yMax - yMin)) * plotH;
|
const int cn = (int)candles.size();
|
||||||
points[i] = ImVec2(x, y);
|
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).
|
// High/low price labels at the displayed range's extremes (no dot markers). Line only — the
|
||||||
for (size_t i = 0; i < n; i++) dl->PathLineTo(points[i]);
|
// candlestick wicks already show each period's high/low.
|
||||||
dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
|
if (!hasCandles && n >= 3) {
|
||||||
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) {
|
|
||||||
size_t hiIdx = (size_t)(std::max_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
|
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());
|
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) {
|
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;
|
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)
|
mousePos.y >= plotTop && mousePos.y <= plotBottom + labelPadBottom)
|
||||||
{
|
{
|
||||||
float mx = mousePos.x - plotLeft;
|
float mx = mousePos.x - plotLeft;
|
||||||
@@ -1927,6 +1953,8 @@ void RenderMarketTab(App* app)
|
|||||||
s_mkt.history.push_back(pr.second);
|
s_mkt.history.push_back(pr.second);
|
||||||
chartTimes.push_back(pr.first);
|
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).
|
// Change over the displayed period (Live falls back to the market's 24h figure).
|
||||||
double periodChangePct = market.change_24h;
|
double periodChangePct = market.change_24h;
|
||||||
if (s_mkt.chartInterval != 0 && s_mkt.history.size() >= 2 && s_mkt.history.front() > 0.0)
|
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{
|
MktCtx mktc{
|
||||||
app, ¤tExchange,
|
app, ¤tExchange,
|
||||||
chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH,
|
chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH,
|
||||||
&chartTimes, nowSec, chartUp, periodChangePct, periodSuffix
|
&chartTimes, &ohlcCandles, nowSec, chartUp, periodChangePct, periodSuffix
|
||||||
};
|
};
|
||||||
mktDrawPriceHero(mktc);
|
mktDrawPriceHero(mktc);
|
||||||
|
|
||||||
|
|||||||
@@ -2962,6 +2962,47 @@ void testExchangeCandles()
|
|||||||
m.exchange_chart_active = true;
|
m.exchange_chart_active = true;
|
||||||
auto exch = chartSeries(m, 4, now);
|
auto exch = chartSeries(m, 4, now);
|
||||||
EXPECT_TRUE(!exch.empty() && exch.back().second > 4.0); // exchange values
|
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
|
// Regression: the Market-tab portfolio (and other consumers) read the combined
|
||||||
|
|||||||
Reference in New Issue
Block a user