From d1ff58c374c56bbb4adb295db57f1f15e8497106 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 12 Jul 2026 09:19:55 -0500 Subject: [PATCH] feat(market): per-exchange price chart from each venue's own candle API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The market chart was hardwired to CoinGecko's cross-exchange USD aggregate (vs_currency=usd), so selecting a trading pair only changed the "Trade" link — never the chart or price. Now the SELECTED exchange drives both. CoinGecko gives us which venues list DRGX (the ticker `market.identifier`) but not their APIs, so add data/exchange_candles.h: a hand-maintained map from that identifier to each venue's public candle endpoint, with two adapters verified against the live APIs — Ourbit (MEXC-style /api/v3/klines, array format) and NonKYC (TradingView-UDF /market/candles, `bars`). Unmapped venues return no URL, so the chart falls back to the CoinGecko aggregate and nothing regresses. - App::refreshExchangeChart() resolves the selected pair, fetches its intraday (5-min) + daily candles via the TLS-verified httpGetString on the worker, and stores them on MarketInfo (exchange_chart_intraday/daily + exchange_chart_active). Re-fetches on pair change; self-throttled to ~30 min otherwise; falls back to aggregate on any failure. - chartSeries() draws the per-exchange series when active (portfolio sparklines stay on the aggregate). The hero shows the selected venue's real price (converted_last) — Ourbit vs NonKYC differ ~7%. parseCoinGeckoTickers now captures the identifier + per-exchange USD price (previously discarded). Adapters unit-tested against real captured responses (URL builder + both parsers + the chartSeries switch) and validated against the full live feeds (Ourbit 20d, NonKYC 370d, ascending, correct closes). Build + ctest + hygiene green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.h | 11 +++- src/app_network.cpp | 68 +++++++++++++++++++++++ src/data/exchange_candles.h | 100 ++++++++++++++++++++++++++++++++++ src/data/exchange_info.cpp | 17 ++++-- src/data/exchange_info.h | 2 + src/data/market_series.h | 12 ++-- src/data/wallet_state.h | 8 +++ src/ui/windows/market_tab.cpp | 11 +++- tests/test_phase4.cpp | 62 +++++++++++++++++++++ 9 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 src/data/exchange_candles.h diff --git a/src/app.h b/src/app.h index b82e3d6..081f7fc 100644 --- a/src/app.h +++ b/src/app.h @@ -335,7 +335,11 @@ public: // Fetch historical USD price series (CoinGecko market_chart) that back the portfolio // sparkline intervals; self-throttled to ~30 min. Safe to call every frame. void refreshMarketChart(); - + // Fetch the SELECTED pair's candles from that exchange's own API (data/exchange_candles.h) so the + // Market chart shows the real per-exchange price. Re-fetches on pair change; falls back to the + // CoinGecko aggregate for unmapped venues / failed fetches. Safe to call every frame. + void refreshExchangeChart(); + /// @brief Per-category refresh intervals, adjusted by active tab using RefreshIntervals = services::NetworkRefreshService::Intervals; @@ -675,6 +679,11 @@ private: bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied bool exchanges_fetch_started_ = false; // once-per-session CoinGecko tickers fetch bool chart_fetch_in_flight_ = false; // a market_chart history fetch is on the worker + // Per-exchange candle chart (refreshExchangeChart): which pair the loaded series is for, an + // in-flight guard, and a slow refresh timer (candles move slowly, like the aggregate chart). + std::string exchange_chart_key_; // ":/" of the loaded series ("" = none) + bool exchange_chart_fetch_in_flight_ = false; + std::chrono::steady_clock::time_point exchange_chart_last_fetch_{}; util::AsyncTaskManager async_tasks_; bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog diff --git a/src/app_network.cpp b/src/app_network.cpp index da01a66..ea02218 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -50,6 +50,8 @@ #include "default_banlist_embedded.h" #include "util/amount_format.h" #include "util/http_download.h" +#include "data/exchange_info.h" +#include "data/exchange_candles.h" #include "util/platform.h" #include "util/perf_log.h" #include "util/i18n.h" @@ -1768,6 +1770,72 @@ void App::refreshMarketData() refreshPrice(); refreshExchanges(); refreshMarketChart(); + refreshExchangeChart(); +} + +void App::refreshExchangeChart() +{ + if (!settings_ || !settings_->getFetchPrices() || !worker_) return; + if (exchange_chart_fetch_in_flight_) return; + + // Resolve the selected pair from the same registry the Market tab renders (live tickers, else the + // static fallback), matching exchange name THEN pair to disambiguate the same pair across venues. + const auto& reg = state_.market.exchanges.empty() + ? data::getExchangeRegistry() : state_.market.exchanges; + const std::string selEx = settings_->getSelectedExchange(); + const std::string selPair = settings_->getSelectedPair(); + const data::ExchangePair* pair = nullptr; + for (const auto& ex : reg) { + if (!selEx.empty() && ex.name != selEx) continue; + for (const auto& p : ex.pairs) + if (p.displayName == selPair) { pair = &p; break; } + if (pair) break; + } + if (!pair && !reg.empty() && !reg[0].pairs.empty()) pair = ®[0].pairs[0]; // default like the UI + if (!pair) { state_.market.exchange_chart_active = false; exchange_chart_key_.clear(); return; } + + const std::string key = pair->identifier + ":" + pair->base + "/" + pair->quote; + + // No candle adapter for this venue -> draw the CoinGecko aggregate instead. + if (pair->identifier.empty() || !data::hasExchangeCandleAdapter(pair->identifier)) { + state_.market.exchange_chart_active = false; + exchange_chart_key_.clear(); + return; + } + // Already loaded for this pair and still fresh (candles move slowly). + if (key == exchange_chart_key_ && state_.market.exchange_chart_active && + std::chrono::steady_clock::now() - exchange_chart_last_fetch_ < std::chrono::minutes(30)) + return; + // Switching pairs: fall back to the aggregate until the new venue's candles arrive. + if (key != exchange_chart_key_) state_.market.exchange_chart_active = false; + + exchange_chart_fetch_in_flight_ = true; + const std::string identifier = pair->identifier, base = pair->base, quote = pair->quote; + const std::time_t now = std::time(nullptr); + const std::string urlIntra = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Intraday, now); + const std::string urlDaily = data::buildExchangeCandleUrl(identifier, base, quote, data::CandleRange::Daily, now); + + worker_->post([this, identifier, key, urlIntra, urlDaily]() -> rpc::RPCWorker::MainCb { + // 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 { + 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); + state_.market.exchange_chart_active = true; + exchange_chart_key_ = key; + exchange_chart_last_fetch_ = std::chrono::steady_clock::now(); + } else { + // Both ranges empty -> the venue's API failed/changed; keep the aggregate. + state_.market.exchange_chart_active = false; + exchange_chart_key_.clear(); + } + }; + }); } void App::refreshMarketChart() diff --git a/src/data/exchange_candles.h b/src/data/exchange_candles.h new file mode 100644 index 0000000..8fa2ad9 --- /dev/null +++ b/src/data/exchange_candles.h @@ -0,0 +1,100 @@ +#pragma once +// Per-exchange OHLC candle adapters. CoinGecko tells us WHICH exchanges list DRGX (via the ticker +// `market.identifier`, e.g. "ourbit" / "nonkyc_io") but NOT their API — this is the hand-maintained +// mapping from that identifier to each venue's public candle endpoint, so the market chart can show the +// SELECTED exchange's real price history instead of CoinGecko's cross-exchange aggregate. +// +// Header-only + pure (no I/O — the actual HTTP fetch happens in app_network.cpp via util::httpGetString) +// so the URL builder + parsers are unit-tested against real captured responses. An unmapped exchange +// returns an empty URL, so the caller falls back to the CoinGecko aggregate and nothing regresses. +#include +#include +#include +#include +#include + +#include + +namespace dragonx { +namespace data { + +using PricePoint = std::pair; // (epoch SECONDS, close price) + +enum class CandleRange { + Intraday, // ~2 days of 5-minute candles — backs the Live / 1H / 1D views + Daily, // ~1 year of daily candles — backs the 1W / 1M views +}; + +// True when we have a candle adapter for this CoinGecko exchange identifier. +inline bool hasExchangeCandleAdapter(const std::string& identifier) { + return identifier == "ourbit" || identifier == "nonkyc_io"; +} + +// Build the venue's candle URL for a pair + range. Returns "" when the exchange isn't mapped. +// `now` is epoch seconds, passed in (no hidden clock reads) so URLs are deterministic in tests. +inline std::string buildExchangeCandleUrl(const std::string& identifier, const std::string& base, + const std::string& quote, CandleRange range, std::time_t now) { + if (identifier == "ourbit") { + // Ourbit = MEXC-style /api/v3/klines: symbol=BASEQUOTE, interval=5m|1d, limit. + const char* iv = (range == CandleRange::Intraday) ? "5m" : "1d"; + const int limit = (range == CandleRange::Intraday) ? 576 : 365; + return "https://api.ourbit.com/api/v3/klines?symbol=" + base + quote + + "&interval=" + iv + "&limit=" + std::to_string(limit); + } + if (identifier == "nonkyc_io") { + // NonKYC = TradingView-UDF candles: symbol=BASE_QUOTE, resolution in minutes, from/to seconds. + const char* res = (range == CandleRange::Intraday) ? "5" : "1440"; + const std::time_t span = (range == CandleRange::Intraday) ? (std::time_t)2 * 24 * 3600 + : (std::time_t)365 * 24 * 3600; + return "https://api.nonkyc.io/api/v2/market/candles?symbol=" + base + "_" + quote + + "&resolution=" + res + "&from=" + std::to_string(now - span) + "&to=" + std::to_string(now); + } + return ""; +} + +namespace detail { +// Read a JSON value that may be a number or a numeric string (Ourbit sends prices as strings). +inline double jnum(const nlohmann::json& v) { + if (v.is_number()) return v.get(); + if (v.is_string()) { try { return std::stod(v.get()); } catch (...) { return 0.0; } } + return 0.0; +} +} // 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; + if (body.empty()) return out; + try { + const nlohmann::json j = nlohmann::json::parse(body); + if (identifier == "ourbit") { + // [[openTime_ms, open, high, low, close, volume, closeTime, quoteVol], ...] + if (!j.is_array()) return out; + 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); + } + } else if (identifier == "nonkyc_io") { + // {"bars":[{"time":ms,"open":..,"high":..,"low":..,"close":..,"volume":..}, ...]} + if (!j.contains("bars") || !j["bars"].is_array()) return out; + 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); + } + } + } catch (...) { + return {}; + } + std::sort(out.begin(), out.end(), + [](const PricePoint& a, const PricePoint& b) { return a.first < b.first; }); + return out; +} + +} // namespace data +} // namespace dragonx diff --git a/src/data/exchange_info.cpp b/src/data/exchange_info.cpp index 668b754..2ef449a 100644 --- a/src/data/exchange_info.cpp +++ b/src/data/exchange_info.cpp @@ -22,14 +22,14 @@ const std::vector& getExchangeRegistry() "Nonkyc.io", "https://nonkyc.io", { - {"DRGX", "USDT", "DRGX/USDT", "https://nonkyc.io/market/DRGX_USDT"}, + {"DRGX", "USDT", "DRGX/USDT", "https://nonkyc.io/market/DRGX_USDT", "nonkyc_io"}, } }, { "OurBit", "https://www.ourbit.com", { - {"DRGX", "USDT", "DRGX/USDT", "https://www.ourbit.com/exchange/DRGX_USDT"}, + {"DRGX", "USDT", "DRGX/USDT", "https://www.ourbit.com/exchange/DRGX_USDT", "ourbit"}, } }, }; @@ -59,11 +59,16 @@ std::vector parseCoinGeckoTickers(const std::string& body) if (!t.is_object()) continue; const std::string base = t.value("base", std::string{}); const std::string target = t.value("target", std::string{}); - std::string market; - if (t.contains("market") && t["market"].is_object()) - market = t["market"].value("name", std::string{}); + std::string market, identifier; + if (t.contains("market") && t["market"].is_object()) { + market = t["market"].value("name", std::string{}); + identifier = t["market"].value("identifier", std::string{}); // keys the per-exchange candle adapter + } const std::string tradeUrl = t.value("trade_url", std::string{}); if (base.empty() || target.empty() || market.empty()) continue; + double lastUsd = 0.0; // this venue's current USD price — differs per exchange + if (t.contains("converted_last") && t["converted_last"].is_object()) + lastUsd = t["converted_last"].value("usd", 0.0); auto it = byName.find(market); if (it == byName.end()) { @@ -71,7 +76,7 @@ std::vector parseCoinGeckoTickers(const std::string& body) exchanges.push_back(ExchangeInfo{market, originOf(tradeUrl), {}}); it = byName.find(market); } - ExchangePair pair{base, target, base + "/" + target, tradeUrl}; + ExchangePair pair{base, target, base + "/" + target, tradeUrl, identifier, lastUsd}; // Skip duplicate pairs on the same exchange. bool dup = false; for (const auto& p : exchanges[it->second].pairs) diff --git a/src/data/exchange_info.h b/src/data/exchange_info.h index 587c689..a9c84a2 100644 --- a/src/data/exchange_info.h +++ b/src/data/exchange_info.h @@ -18,6 +18,8 @@ struct ExchangePair { std::string quote; ///< e.g. "BTC" std::string displayName; ///< e.g. "DRGX/BTC" std::string tradeUrl; ///< Link to the exchange pair page + std::string identifier; ///< CoinGecko exchange id (e.g. "ourbit", "nonkyc_io") — keys the candle adapter + double lastUsd = 0.0; ///< This venue's current price in USD (CoinGecko converted_last.usd); 0 if unknown }; /** diff --git a/src/data/market_series.h b/src/data/market_series.h index d5af3eb..3d0d42c 100644 --- a/src/data/market_series.h +++ b/src/data/market_series.h @@ -81,11 +81,15 @@ inline std::vector> chartSeries(const MarketInfo& 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(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } // 1H - case 2: { auto v = lastWindow(m.price_chart_intraday, kDay); if (v.size() >= 2) return v; break; } // 1D - case 3: { auto v = lastWindow(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W - case 4: { auto v = lastWindow(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M + 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> out; diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index f44ff1a..3dfacea 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -175,6 +175,14 @@ struct MarketInfo { std::chrono::steady_clock::time_point chart_last_fetch_time{}; bool chart_loaded = false; + // Per-EXCHANGE candle series for the SELECTED pair, fetched from that venue's own API (see + // data/exchange_candles.h). When exchange_chart_active is true the Market chart draws these instead + // of the CoinGecko cross-exchange aggregate above; it's set false while switching pairs or when the + // selected venue has no adapter / its fetch failed, so the chart gracefully falls back to aggregate. + std::vector> exchange_chart_intraday; + std::vector> exchange_chart_daily; + bool exchange_chart_active = false; + // Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab // falls back to data::getExchangeRegistry() while empty). std::vector exchanges; diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index 8a6e764..9500bd3 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -1240,10 +1240,17 @@ static void mktDrawPriceHero(const MktCtx& cx) float cx0 = cardMin.x + Layout::spacingLg(); float cy = cardMin.y + Layout::spacingLg(); - if (market.price_usd > 0) { + // Prefer the SELECTED exchange's own current price (it differs per venue — Ourbit vs NonKYC can be + // ~7% apart); fall back to the CoinGecko cross-exchange aggregate when the venue price is unknown. + double heroPrice = market.price_usd; + if (!currentExchange.pairs.empty() && s_mkt.pairIdx < (int)currentExchange.pairs.size() + && currentExchange.pairs[s_mkt.pairIdx].lastUsd > 0.0) + heroPrice = currentExchange.pairs[s_mkt.pairIdx].lastUsd; + + if (heroPrice > 0) { // ---- HERO PRICE (large, prominent) ---- ImFont* h3 = Type().h3(); - std::string priceStr = FormatPrice(market.price_usd); + std::string priceStr = FormatPrice(heroPrice); ImU32 priceCol = Success(); DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx0, cy), priceCol, priceStr.c_str()); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 021714d..462c145 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -48,6 +48,7 @@ #include "data/wallet_state.h" #include "data/portfolio.h" #include "data/market_series.h" +#include "data/exchange_candles.h" #include "fake_lite_backend.h" #include @@ -2903,6 +2904,66 @@ void testMarketSeries() EXPECT_NEAR(live.back().second, 3.0, 1e-9); } +// Per-exchange candle adapters (data/exchange_candles.h) — the URL builder + the Ourbit (array) / +// NonKYC (UDF bars) parsers, verified against real captured API responses, plus the chartSeries +// switch that draws the per-exchange series when it's active. +void testExchangeCandles() +{ + using dragonx::data::buildExchangeCandleUrl; + using dragonx::data::parseExchangeCandles; + using dragonx::data::hasExchangeCandleAdapter; + using dragonx::data::CandleRange; + + // --- URL builder (deterministic given `now`) --- + EXPECT_TRUE(buildExchangeCandleUrl("ourbit", "DRGX", "USDT", CandleRange::Intraday, 1000) + == "https://api.ourbit.com/api/v3/klines?symbol=DRGXUSDT&interval=5m&limit=576"); + EXPECT_TRUE(buildExchangeCandleUrl("ourbit", "DRGX", "USDT", CandleRange::Daily, 1000) + == "https://api.ourbit.com/api/v3/klines?symbol=DRGXUSDT&interval=1d&limit=365"); + EXPECT_TRUE(buildExchangeCandleUrl("nonkyc_io", "DRGX", "USDT", CandleRange::Daily, 1000000000) + == "https://api.nonkyc.io/api/v2/market/candles?symbol=DRGX_USDT&resolution=1440&from=968464000&to=1000000000"); + EXPECT_TRUE(buildExchangeCandleUrl("binance", "DRGX", "USDT", CandleRange::Daily, 1000).empty()); + EXPECT_TRUE(hasExchangeCandleAdapter("ourbit") && hasExchangeCandleAdapter("nonkyc_io")); + EXPECT_TRUE(!hasExchangeCandleAdapter("kraken")); + + // --- Ourbit parse: array of arrays, close at index 4, prices as strings, time in ms --- + auto o = parseExchangeCandles("ourbit", + R"([[1783728000000,"0.01298","0.01406","0.01286","0.01395","1203855.69",1783814400000,"16132.16809"],)" + R"([1783814400000,"0.01395","0.01418","0.01364","0.01371","584276.8",1783900800000,"8126.07742"]])"); + EXPECT_EQ(o.size(), static_cast(2)); + EXPECT_NEAR(o[0].second, 0.01395, 1e-9); // close = idx 4 + EXPECT_EQ(static_cast(o[0].first), 1783728000L); // ms -> seconds + EXPECT_TRUE(o[0].first < o[1].first); // ascending + + // --- NonKYC parse: {"bars":[{time,close,...}]}, close numeric, time in ms --- + auto n = parseExchangeCandles("nonkyc_io", + R"({"bars":[{"time":1781308800000,"close":0.031354,"open":0.032559,"high":0.034999,"low":0.02804,"volume":23550.357},)" + R"({"time":1781395200000,"close":0.02555,"open":0.031091,"high":0.03296,"low":0.025035,"volume":17657.726}]})"); + EXPECT_EQ(n.size(), static_cast(2)); + EXPECT_NEAR(n[0].second, 0.031354, 1e-9); + EXPECT_EQ(static_cast(n[0].first), 1781308800L); + + // --- robustness: empty / garbage / no-data / unmapped -> empty --- + EXPECT_TRUE(parseExchangeCandles("ourbit", "").empty()); + EXPECT_TRUE(parseExchangeCandles("ourbit", "not json").empty()); + EXPECT_TRUE(parseExchangeCandles("nonkyc_io", R"({"bars":[],"meta":{"noData":true}})").empty()); + EXPECT_TRUE(parseExchangeCandles("binance", "[[1,2,3,4,5]]").empty()); + + // --- chartSeries prefers the per-exchange series when active, else the aggregate --- + using dragonx::MarketInfo; + using dragonx::data::chartSeries; + std::time_t now = 2000000000; + MarketInfo m; + // Aggregate daily (two points in the last 30d) vs a DISTINCT exchange daily series. + m.price_chart_daily = { {now - 10 * 86400, 1.0}, {now - 1 * 86400, 1.1} }; + m.exchange_chart_daily = { {now - 10 * 86400, 5.0}, {now - 1 * 86400, 5.5} }; + m.exchange_chart_active = false; + auto agg = chartSeries(m, 4, now); // 1M + EXPECT_TRUE(!agg.empty() && agg.back().second < 2.0); // aggregate values + m.exchange_chart_active = true; + auto exch = chartSeries(m, 4, now); + EXPECT_TRUE(!exch.empty() && exch.back().second > 4.0); // exchange values +} + // Regression: the Market-tab portfolio (and other consumers) read the combined // WalletState::addresses view, which the full-node refresh path builds from the // authoritative z/t lists via rebuildAddressList(). Before the fix that rebuild @@ -6281,6 +6342,7 @@ int main() testConsoleModel(); testPortfolioHelpers(); testMarketSeries(); + testExchangeCandles(); testWalletStateAddressListRebuild(); testConsoleFoldSpans(); testConsoleScrollController();