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>
127 lines
6.3 KiB
C++
127 lines
6.3 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#pragma once
|
|
|
|
#include <vector>
|
|
#include <utility>
|
|
#include <ctime>
|
|
|
|
#include "wallet_state.h" // MarketInfo
|
|
|
|
// Pure (no-I/O, no-ImGui) price-series math backing the market chart and the portfolio-group
|
|
// sparklines. Kept header-only + inline so it can be unit-tested without the GUI (see
|
|
// tests/test_phase4.cpp), mirroring data/portfolio.h.
|
|
namespace dragonx {
|
|
namespace data {
|
|
|
|
// Resample a ~1-sample/minute price history to the given interval by averaging each block of K
|
|
// minute-samples into one point. interval: 0=min 1=hour 2=day 3=week 4=month.
|
|
inline std::vector<double> resampleHistory(const std::vector<double>& hist, int interval)
|
|
{
|
|
static const int kMins[5] = {1, 60, 1440, 10080, 43200};
|
|
int k = kMins[(interval >= 0 && interval < 5) ? interval : 0];
|
|
if (k <= 1) return hist;
|
|
std::vector<double> out;
|
|
for (size_t i = 0; i < hist.size(); i += (size_t)k) {
|
|
double sum = 0.0; size_t cnt = 0;
|
|
for (size_t j = i; j < hist.size() && j < i + (size_t)k; ++j) { sum += hist[j]; ++cnt; }
|
|
if (cnt) out.push_back(sum / (double)cnt);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Bucket a timestamped (unix-seconds, price) series into fixed-width time windows, averaging the
|
|
// samples in each window into one point. Input is oldest->newest; output preserves that order.
|
|
inline std::vector<double> bucketBySeconds(
|
|
const std::vector<std::pair<std::time_t, double>>& series, long windowSec)
|
|
{
|
|
std::vector<double> out;
|
|
if (series.empty() || windowSec <= 0) return out;
|
|
long curBucket = 0; double sum = 0.0; int cnt = 0;
|
|
for (const auto& sample : series) {
|
|
long b = (long)(sample.first / windowSec);
|
|
if (cnt > 0 && b != curBucket) { out.push_back(sum / cnt); sum = 0.0; cnt = 0; }
|
|
curBucket = b;
|
|
sum += sample.second; ++cnt;
|
|
}
|
|
if (cnt > 0) out.push_back(sum / cnt);
|
|
return out;
|
|
}
|
|
|
|
// Resolve the price series backing a group's sparkline for the chosen interval:
|
|
// 0=minute -> the live in-session buffer; 1=hour -> intraday (5-min) data bucketed to hours;
|
|
// 2=day / 3=week / 4=month -> the ~1yr daily series bucketed accordingly.
|
|
// Falls back to the in-session buffer when the historical fetch hasn't populated yet.
|
|
inline std::vector<double> sparklineSeries(const MarketInfo& m, int interval)
|
|
{
|
|
const long kDay = 86400;
|
|
switch (interval) {
|
|
case 1: { auto v = bucketBySeconds(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; }
|
|
case 2: { auto v = bucketBySeconds(m.price_chart_daily, kDay); if (v.size() >= 2) return v; break; }
|
|
case 3: { auto v = bucketBySeconds(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; }
|
|
case 4: { auto v = bucketBySeconds(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; }
|
|
default: break; // minute interval, or no historical data yet -> live buffer below
|
|
}
|
|
return resampleHistory(m.price_history, interval);
|
|
}
|
|
|
|
// Timestamped price series backing the main chart for the selected RANGE (Live/1H/1D/1W/1M),
|
|
// ending at `now`. The interval buttons select a time window ending at `now`: 1H = last hour,
|
|
// 1D = last 24h, 1W = last 7 days, 1M = last 30 days. 1H/1D come from the 5-minute intraday
|
|
// series; 1W/1M from the daily series. Live (or an un-fetched/empty range) uses the minute buffer.
|
|
inline std::vector<std::pair<std::time_t, double>> chartSeries(const MarketInfo& m, int interval,
|
|
std::time_t now)
|
|
{
|
|
const long kDay = 86400;
|
|
auto lastWindow = [now](const std::vector<std::pair<std::time_t, double>>& src, long rangeSec) {
|
|
std::vector<std::pair<std::time_t, double>> out;
|
|
std::time_t cutoff = now - (std::time_t)rangeSec;
|
|
for (const auto& s : src) if (s.first >= cutoff) out.push_back(s);
|
|
return out;
|
|
};
|
|
// Draw the SELECTED exchange's own candles when active (data/exchange_candles.h), else the CoinGecko
|
|
// cross-exchange aggregate. Only the main chart switches source; portfolio sparklines stay aggregate.
|
|
const auto& intraday = m.exchange_chart_active ? m.exchange_chart_intraday : m.price_chart_intraday;
|
|
const auto& daily = m.exchange_chart_active ? m.exchange_chart_daily : m.price_chart_daily;
|
|
switch (interval) {
|
|
case 1: { auto v = lastWindow(intraday, 3600); if (v.size() >= 2) return v; break; } // 1H
|
|
case 2: { auto v = lastWindow(intraday, kDay); if (v.size() >= 2) return v; break; } // 1D
|
|
case 3: { auto v = lastWindow(daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W
|
|
case 4: { auto v = lastWindow(daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M
|
|
default: break;
|
|
}
|
|
std::vector<std::pair<std::time_t, double>> out;
|
|
const auto& h = m.price_history;
|
|
for (size_t i = 0; i < h.size(); i++)
|
|
out.push_back({ now - (std::time_t)((h.size() - 1 - i) * 60), h[i] });
|
|
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 dragonx
|