12 Commits

Author SHA1 Message Date
bfe8b4d77d change(settings): default Windows window opacity to 90%
Raise the Windows window-opacity default (0.75 -> 0.90) so less of the
(often dark) desktop bleeds through the wallpaper behind the panels.
Mac/Linux stay fully opaque. Only affects fresh installs / unset values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:40:40 -05:00
78c00daf92 change(modals): raise the fixed modal-backdrop blur to 120px
Bump the hardcoded modal blur radius (96 -> 120) for a softer, more
recognizable frost behind dialogs. Still independent of the acrylic slider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:40:40 -05:00
e82514f46a feat(market): persist chart range + style across sessions
The chart interval (Live/1H/1D/1W/1M) and the line/candle style were
session-only statics. Persist both like the selected exchange/pair: new
chart_interval / chart_style settings (defaults 1M / candlestick),
loaded into the market view on first show and saved on each interval
click / style toggle.

Verified: writing chart_interval=2, chart_style=0 to settings.json and
launching restores them (not reset to defaults) and re-saves them intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:05:36 -05:00
76708e9191 feat(market): line/candle toggle + ~50% taller chart
- Add a chart-style toggle (line vs candlestick) next to the interval buttons,
  shown only when the selected range has per-exchange candles (the aggregate /
  Live view is line-only). It flips s_mkt.chartStyle; candles draw when OHLC
  exists AND the user hasn't switched to the line. Icon reflects the current
  style; tooltip says what a click switches to. Two i18n keys across 8 languages.
- Make the price chart ~50% taller: scale the height floor / desired / viewport
  cap (110->165, x1.5 desired, 0.22->0.33 of available) for a roomier plot.

Verified via a forced-OHLC render: the taller chart, the toggle button, and
candles with a correct week/day x-axis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:54:08 -05:00
a767702ff6 feat(market): OHLC hover readout on the candlestick chart
Candles had no hover (the line's close tooltip is gated off for them). Add a
candle-aware readout: the candle under the cursor gets a column highlight +
crosshair, and a small box shows its date and open/high/low/close (colored green
up / red down). Verified via a forced-hover render (date + O/H/L/C over the
highlighted candle). Line charts keep their existing single-price tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:46:26 -05:00
40e8128a30 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>
2026-07-12 11:16:44 -05:00
326192e75e feat(market): per-exchange 24h volume in the hero
The hero's "24H Vol" showed CoinGecko's cross-exchange aggregate even after the
chart/price went per-venue. Capture the per-exchange converted_volume.usd from
the tickers (previously discarded, alongside converted_last) and show the
SELECTED exchange's own 24h volume; fall back to the aggregate when unknown.
Market cap stays aggregate (it's coin-wide, not per-venue).

This is a big real difference — e.g. Ourbit ~$14.3K vs NonKYC ~$253 for DRGX/USDT
— so the header now reflects the venue you're actually looking at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:58:50 -05:00
a107fda9b8 feat(market): loading spinner while the price chart fetches (pair switches)
Switching exchange pairs (or first load) briefly leaves the chart with no series
while the venue's candles fetch, which showed the bare "No price history
available" empty state. When a chart fetch is in flight — App::isMarketChartLoading()
(the CoinGecko aggregate OR the per-exchange fetch) — draw a spinner + animated
"Loading price history…" in the plot area instead; the "no history" text only
shows when genuinely empty and idle. New i18n key market_chart_loading across all
8 languages. Verified via a forced-state render (spinner + label centered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 09:38:44 -05:00
d1ff58c374 feat(market): per-exchange price chart from each venue's own candle API
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) <noreply@anthropic.com>
2026-07-12 09:19:55 -05:00
156735eb06 feat(modals): fixed modal-backdrop blur, independent of the acrylic slider
The modal backdrop blur was params.blurRadius (96) scaled by the user's global
acrylic blur-strength slider (blurRadiusMultiplier), so lowering the slider also
weakened every modal's backdrop. Make the modal blur a fixed hardcoded value.

Add AcrylicParams::absoluteBlurRadius: when set, applyBlur() uses the radius
as-is and skips the multiplier (threaded through both the GL and DX11 blur paths
+ the no-op stub). DrawFullWindowBlurBackdrop sets it, so the modal backdrop is
always a 96px blur regardless of the slider. Panels/other acrylic still scale
with the slider as before.

Verified: rendering a modal at blur_multiplier 0.1 vs 2.0 now produces an
identical backdrop (max pixel diff 1/255) — previously those were ~9.6px vs
~192px of blur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 07:41:53 -05:00
2f86bec98e feat(wallets): subtle empty-state hint in the fixed-height list
With the list now pinned at max height, a few wallets leave blank space below
the cards. Fill it — only when there's real room to spare — with a subtle
centered folder glyph + "Scan a folder to find more wallets" (OnSurfaceDisabled),
so the area reads as a gentle nudge toward the scan action rather than dead
space. Purely decorative (draw-list only, clipped to the list); the actions stay
below. New i18n key wallets_empty_hint, translated across all 8 languages.

Verified on the sweep: the hint centers in the gap and reads subtly on both dark
and light themes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 07:17:34 -05:00
6310f51f65 feat(wallets): fixed max-height list + manage scanned folders
Two wallets-modal changes:

- The wallet list now always renders at its max height (kMaxVisibleRows) instead
  of shrinking to the wallet count, so the modal is a consistent size whether you
  have one wallet or many — fewer rows leave empty space, more than 7 scroll.

- Add a "Scanned folders" manager: each user-added scan folder is listed with a
  control to stop scanning it (removeExtraFolder + save + re-scan so its wallets
  drop out). Paths front-elide to keep the identifying leaf visible, with a
  full-path tooltip. The card-height math reserves the manager's rows so nothing
  clips. Two new i18n keys (wallets_scanned_folders / wallets_remove_folder),
  translated across all 8 languages; CJK subset rebuilt.

Verified on the sweep at 1.0 and 1.5x DPI: max-height list on both the few- and
many-wallet surfaces, the folder manager renders and stays clear of the footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 07:04:43 -05:00
27 changed files with 776 additions and 59 deletions

Binary file not shown.

View File

@@ -534,6 +534,7 @@
"market_btc_price": "BTC PREIS", "market_btc_price": "BTC PREIS",
"market_cap": "Marktkapitalisierung", "market_cap": "Marktkapitalisierung",
"market_cap_short": "Kap.", "market_cap_short": "Kap.",
"market_chart_loading": "Preisverlauf wird geladen",
"market_iv_1d": "1T", "market_iv_1d": "1T",
"market_iv_1h": "1S", "market_iv_1h": "1S",
"market_iv_1m": "1M", "market_iv_1m": "1M",
@@ -547,6 +548,8 @@
"market_price_loading": "Preisdaten werden geladen...", "market_price_loading": "Preisdaten werden geladen...",
"market_price_unavailable": "Preisdaten nicht verfügbar", "market_price_unavailable": "Preisdaten nicht verfügbar",
"market_refresh_price": "Preisdaten aktualisieren", "market_refresh_price": "Preisdaten aktualisieren",
"market_style_candle": "Zu Kerzenchart wechseln",
"market_style_line": "Zum Liniendiagramm wechseln",
"market_trade_on": "Handeln auf %s", "market_trade_on": "Handeln auf %s",
"market_updated": "\\xc2\\xb7 Aktualisiert %s", "market_updated": "\\xc2\\xb7 Aktualisiert %s",
"market_vol_short": "Vol.", "market_vol_short": "Vol.",
@@ -1343,6 +1346,7 @@
"wallets_created": "erstellt", "wallets_created": "erstellt",
"wallets_creating": "Wallet wird erstellt — der Node wird neu gestartet…", "wallets_creating": "Wallet wird erstellt — der Node wird neu gestartet…",
"wallets_current": "aktuell", "wallets_current": "aktuell",
"wallets_empty_hint": "Ordner durchsuchen, um weitere Wallets zu finden",
"wallets_exists": "Eine Wallet mit diesem Namen existiert bereits.", "wallets_exists": "Eine Wallet mit diesem Namen existiert bereits.",
"wallets_external_tt": "Außerhalb deines Datenverzeichnisses — Öffnen verlinkt sie an ihrem Ort (keine Kopie).", "wallets_external_tt": "Außerhalb deines Datenverzeichnisses — Öffnen verlinkt sie an ihrem Ort (keine Kopie).",
"wallets_folder_hint": "/pfad/zum/ordner mit .dat-Wallet-Dateien", "wallets_folder_hint": "/pfad/zum/ordner mit .dat-Wallet-Dateien",
@@ -1356,7 +1360,9 @@
"wallets_open_failed": "Diese Wallet konnte nicht an ihrem Ort geöffnet werden. Sie liegt vermutlich auf einem anderen Laufwerk als dein Datenverzeichnis — verschiebe sie auf dasselbe Laufwerk (unter Windows erlaubt auch der aktivierte Entwicklermodus laufwerkübergreifende Verknüpfungen).", "wallets_open_failed": "Diese Wallet konnte nicht an ihrem Ort geöffnet werden. Sie liegt vermutlich auf einem anderen Laufwerk als dein Datenverzeichnis — verschiebe sie auf dasselbe Laufwerk (unter Windows erlaubt auch der aktivierte Entwicklermodus laufwerkübergreifende Verknüpfungen).",
"wallets_open_folder": "Ordnerpfad öffnen", "wallets_open_folder": "Ordnerpfad öffnen",
"wallets_open_inplace_tt": "Öffnet diese Wallet an ihrem Ort — im Datenverzeichnis verlinkt (keine Kopie)", "wallets_open_inplace_tt": "Öffnet diese Wallet an ihrem Ort — im Datenverzeichnis verlinkt (keine Kopie)",
"wallets_remove_folder": "Diesen Ordner nicht mehr durchsuchen",
"wallets_reveal": "Ordner anzeigen", "wallets_reveal": "Ordner anzeigen",
"wallets_scanned_folders": "Durchsuchte Ordner:",
"wallets_sort_addresses": "Adressen", "wallets_sort_addresses": "Adressen",
"wallets_sort_asc": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)", "wallets_sort_asc": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)",
"wallets_sort_by": "Sortieren:", "wallets_sort_by": "Sortieren:",

View File

@@ -534,6 +534,7 @@
"market_btc_price": "PRECIO BTC", "market_btc_price": "PRECIO BTC",
"market_cap": "Cap. de Mercado", "market_cap": "Cap. de Mercado",
"market_cap_short": "Cap.", "market_cap_short": "Cap.",
"market_chart_loading": "Cargando historial de precios",
"market_iv_1d": "1D", "market_iv_1d": "1D",
"market_iv_1h": "1H", "market_iv_1h": "1H",
"market_iv_1m": "1M", "market_iv_1m": "1M",
@@ -547,6 +548,8 @@
"market_price_loading": "Cargando datos de precio...", "market_price_loading": "Cargando datos de precio...",
"market_price_unavailable": "Datos de precio no disponibles", "market_price_unavailable": "Datos de precio no disponibles",
"market_refresh_price": "Actualizar datos de precio", "market_refresh_price": "Actualizar datos de precio",
"market_style_candle": "Cambiar a velas",
"market_style_line": "Cambiar a gráfico de líneas",
"market_trade_on": "Operar en %s", "market_trade_on": "Operar en %s",
"market_updated": "\\xc2\\xb7 Actualizado %s", "market_updated": "\\xc2\\xb7 Actualizado %s",
"market_vol_short": "Vol", "market_vol_short": "Vol",
@@ -1343,6 +1346,7 @@
"wallets_created": "creada", "wallets_created": "creada",
"wallets_creating": "Creando cartera — el nodo se reiniciará…", "wallets_creating": "Creando cartera — el nodo se reiniciará…",
"wallets_current": "actual", "wallets_current": "actual",
"wallets_empty_hint": "Escanea una carpeta para encontrar más carteras",
"wallets_exists": "Ya existe una cartera con ese nombre.", "wallets_exists": "Ya existe una cartera con ese nombre.",
"wallets_external_tt": "Fuera de tu directorio de datos — Abrir lo enlaza en su lugar (sin copiar).", "wallets_external_tt": "Fuera de tu directorio de datos — Abrir lo enlaza en su lugar (sin copiar).",
"wallets_folder_hint": "/ruta/a/carpeta con archivos .dat de cartera", "wallets_folder_hint": "/ruta/a/carpeta con archivos .dat de cartera",
@@ -1356,7 +1360,9 @@
"wallets_open_failed": "No se pudo abrir esta cartera en su ubicación. Probablemente está en una unidad distinta a tu directorio de datos: muévela a la misma unidad (en Windows, activar el Modo de desarrollador también permite enlazar entre unidades).", "wallets_open_failed": "No se pudo abrir esta cartera en su ubicación. Probablemente está en una unidad distinta a tu directorio de datos: muévela a la misma unidad (en Windows, activar el Modo de desarrollador también permite enlazar entre unidades).",
"wallets_open_folder": "Abrir ubicación de la carpeta", "wallets_open_folder": "Abrir ubicación de la carpeta",
"wallets_open_inplace_tt": "Abre esta cartera donde está, enlazándola al directorio de datos (sin copiar)", "wallets_open_inplace_tt": "Abre esta cartera donde está, enlazándola al directorio de datos (sin copiar)",
"wallets_remove_folder": "Dejar de escanear esta carpeta",
"wallets_reveal": "Mostrar carpeta", "wallets_reveal": "Mostrar carpeta",
"wallets_scanned_folders": "Carpetas escaneadas:",
"wallets_sort_addresses": "Direcciones", "wallets_sort_addresses": "Direcciones",
"wallets_sort_asc": "Ascendente (más antiguo / menos / más pequeño primero)", "wallets_sort_asc": "Ascendente (más antiguo / menos / más pequeño primero)",
"wallets_sort_by": "Ordenar:", "wallets_sort_by": "Ordenar:",

View File

@@ -534,6 +534,7 @@
"market_btc_price": "PRIX BTC", "market_btc_price": "PRIX BTC",
"market_cap": "Capitalisation", "market_cap": "Capitalisation",
"market_cap_short": "Cap.", "market_cap_short": "Cap.",
"market_chart_loading": "Chargement de l'historique des prix",
"market_iv_1d": "1J", "market_iv_1d": "1J",
"market_iv_1h": "1H", "market_iv_1h": "1H",
"market_iv_1m": "1M", "market_iv_1m": "1M",
@@ -547,6 +548,8 @@
"market_price_loading": "Chargement des données de prix...", "market_price_loading": "Chargement des données de prix...",
"market_price_unavailable": "Données de prix indisponibles", "market_price_unavailable": "Données de prix indisponibles",
"market_refresh_price": "Actualiser les données de prix", "market_refresh_price": "Actualiser les données de prix",
"market_style_candle": "Passer aux chandeliers",
"market_style_line": "Passer au graphique en ligne",
"market_trade_on": "Échanger sur %s", "market_trade_on": "Échanger sur %s",
"market_updated": "\\xc2\\xb7 Mis à jour %s", "market_updated": "\\xc2\\xb7 Mis à jour %s",
"market_vol_short": "Vol", "market_vol_short": "Vol",
@@ -1343,6 +1346,7 @@
"wallets_created": "créé", "wallets_created": "créé",
"wallets_creating": "Création du portefeuille — le nœud va redémarrer…", "wallets_creating": "Création du portefeuille — le nœud va redémarrer…",
"wallets_current": "actuel", "wallets_current": "actuel",
"wallets_empty_hint": "Analysez un dossier pour trouver d'autres portefeuilles",
"wallets_exists": "Un portefeuille portant ce nom existe déjà.", "wallets_exists": "Un portefeuille portant ce nom existe déjà.",
"wallets_external_tt": "Hors de votre répertoire de données — Ouvrir le lie sur place (sans copie).", "wallets_external_tt": "Hors de votre répertoire de données — Ouvrir le lie sur place (sans copie).",
"wallets_folder_hint": "/chemin/vers/le/dossier contenant les fichiers .dat", "wallets_folder_hint": "/chemin/vers/le/dossier contenant les fichiers .dat",
@@ -1356,7 +1360,9 @@
"wallets_open_failed": "Impossible d'ouvrir ce portefeuille à son emplacement. Il se trouve probablement sur un lecteur différent de votre répertoire de données — déplacez-le sur le même lecteur (sous Windows, activer le mode développeur permet aussi de créer des liens entre lecteurs).", "wallets_open_failed": "Impossible d'ouvrir ce portefeuille à son emplacement. Il se trouve probablement sur un lecteur différent de votre répertoire de données — déplacez-le sur le même lecteur (sous Windows, activer le mode développeur permet aussi de créer des liens entre lecteurs).",
"wallets_open_folder": "Ouvrir l'emplacement du dossier", "wallets_open_folder": "Ouvrir l'emplacement du dossier",
"wallets_open_inplace_tt": "Ouvre ce portefeuille à son emplacement — lié au répertoire de données (sans copie)", "wallets_open_inplace_tt": "Ouvre ce portefeuille à son emplacement — lié au répertoire de données (sans copie)",
"wallets_remove_folder": "Ne plus analyser ce dossier",
"wallets_reveal": "Afficher le dossier", "wallets_reveal": "Afficher le dossier",
"wallets_scanned_folders": "Dossiers analysés :",
"wallets_sort_addresses": "Adresses", "wallets_sort_addresses": "Adresses",
"wallets_sort_asc": "Croissant (plus ancien / moins / plus petit d'abord)", "wallets_sort_asc": "Croissant (plus ancien / moins / plus petit d'abord)",
"wallets_sort_by": "Trier :", "wallets_sort_by": "Trier :",

View File

@@ -534,6 +534,7 @@
"market_btc_price": "BTC価格", "market_btc_price": "BTC価格",
"market_cap": "時価総額", "market_cap": "時価総額",
"market_cap_short": "時価総額", "market_cap_short": "時価総額",
"market_chart_loading": "価格履歴を読み込み中",
"market_iv_1d": "1日", "market_iv_1d": "1日",
"market_iv_1h": "1時間", "market_iv_1h": "1時間",
"market_iv_1m": "1ヶ月", "market_iv_1m": "1ヶ月",
@@ -547,6 +548,8 @@
"market_price_loading": "価格データを読み込み中...", "market_price_loading": "価格データを読み込み中...",
"market_price_unavailable": "価格データが利用できません", "market_price_unavailable": "価格データが利用できません",
"market_refresh_price": "価格データを更新", "market_refresh_price": "価格データを更新",
"market_style_candle": "ローソク足に切り替え",
"market_style_line": "折れ線チャートに切り替え",
"market_trade_on": "%s で取引", "market_trade_on": "%s で取引",
"market_updated": "\\xc2\\xb7 更新: %s", "market_updated": "\\xc2\\xb7 更新: %s",
"market_vol_short": "出来高", "market_vol_short": "出来高",
@@ -1343,6 +1346,7 @@
"wallets_created": "作成", "wallets_created": "作成",
"wallets_creating": "ウォレットを作成中 — ノードが再起動します…", "wallets_creating": "ウォレットを作成中 — ノードが再起動します…",
"wallets_current": "現在", "wallets_current": "現在",
"wallets_empty_hint": "フォルダーをスキャンして他のウォレットを探す",
"wallets_exists": "その名前のウォレットは既に存在します。", "wallets_exists": "その名前のウォレットは既に存在します。",
"wallets_external_tt": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。", "wallets_external_tt": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。",
"wallets_folder_hint": "wallet .dat ファイルのあるフォルダのパス", "wallets_folder_hint": "wallet .dat ファイルのあるフォルダのパス",
@@ -1356,7 +1360,9 @@
"wallets_open_failed": "このウォレットをその場で開けませんでした。データディレクトリとは別のドライブにある可能性があります — 同じドライブに移動してくださいWindows では開発者モードを有効にするとドライブ間のリンクも可能になります)。", "wallets_open_failed": "このウォレットをその場で開けませんでした。データディレクトリとは別のドライブにある可能性があります — 同じドライブに移動してくださいWindows では開発者モードを有効にするとドライブ間のリンクも可能になります)。",
"wallets_open_folder": "フォルダーの場所を開く", "wallets_open_folder": "フォルダーの場所を開く",
"wallets_open_inplace_tt": "このウォレットをその場で開きます — データディレクトリにリンク(コピーなし)", "wallets_open_inplace_tt": "このウォレットをその場で開きます — データディレクトリにリンク(コピーなし)",
"wallets_remove_folder": "このフォルダーのスキャンを停止",
"wallets_reveal": "フォルダを開く", "wallets_reveal": "フォルダを開く",
"wallets_scanned_folders": "スキャン対象フォルダー:",
"wallets_sort_addresses": "アドレス", "wallets_sort_addresses": "アドレス",
"wallets_sort_asc": "昇順(古い/少ない/小さい順)", "wallets_sort_asc": "昇順(古い/少ない/小さい順)",
"wallets_sort_by": "並べ替え:", "wallets_sort_by": "並べ替え:",

View File

@@ -534,6 +534,7 @@
"market_btc_price": "BTC 가격", "market_btc_price": "BTC 가격",
"market_cap": "시가총액", "market_cap": "시가총액",
"market_cap_short": "시총", "market_cap_short": "시총",
"market_chart_loading": "가격 기록 불러오는 중",
"market_iv_1d": "1일", "market_iv_1d": "1일",
"market_iv_1h": "1시간", "market_iv_1h": "1시간",
"market_iv_1m": "1개월", "market_iv_1m": "1개월",
@@ -547,6 +548,8 @@
"market_price_loading": "가격 데이터를 불러오는 중...", "market_price_loading": "가격 데이터를 불러오는 중...",
"market_price_unavailable": "가격 데이터를 사용할 수 없습니다", "market_price_unavailable": "가격 데이터를 사용할 수 없습니다",
"market_refresh_price": "가격 데이터 새로고침", "market_refresh_price": "가격 데이터 새로고침",
"market_style_candle": "캔들차트로 전환",
"market_style_line": "선형 차트로 전환",
"market_trade_on": "%s에서 거래", "market_trade_on": "%s에서 거래",
"market_updated": "\\xc2\\xb7 업데이트됨 %s", "market_updated": "\\xc2\\xb7 업데이트됨 %s",
"market_vol_short": "거래량", "market_vol_short": "거래량",
@@ -1343,6 +1346,7 @@
"wallets_created": "생성", "wallets_created": "생성",
"wallets_creating": "지갑을 만드는 중 — 노드가 재시작됩니다…", "wallets_creating": "지갑을 만드는 중 — 노드가 재시작됩니다…",
"wallets_current": "현재", "wallets_current": "현재",
"wallets_empty_hint": "폴더를 스캔하여 다른 지갑 찾기",
"wallets_exists": "같은 이름의 지갑이 이미 존재합니다.", "wallets_exists": "같은 이름의 지갑이 이미 존재합니다.",
"wallets_external_tt": "데이터 디렉터리 밖 — '열기'는 제자리에 링크합니다 (복사 없음).", "wallets_external_tt": "데이터 디렉터리 밖 — '열기'는 제자리에 링크합니다 (복사 없음).",
"wallets_folder_hint": "wallet-*.dat 파일이 있는 폴더 경로", "wallets_folder_hint": "wallet-*.dat 파일이 있는 폴더 경로",
@@ -1356,7 +1360,9 @@
"wallets_open_failed": "이 지갑을 제자리에서 열 수 없습니다. 데이터 디렉터리와 다른 드라이브에 있을 가능성이 높습니다 — 같은 드라이브로 옮기세요 (Windows에서는 개발자 모드를 켜면 드라이브 간 링크도 가능합니다).", "wallets_open_failed": "이 지갑을 제자리에서 열 수 없습니다. 데이터 디렉터리와 다른 드라이브에 있을 가능성이 높습니다 — 같은 드라이브로 옮기세요 (Windows에서는 개발자 모드를 켜면 드라이브 간 링크도 가능합니다).",
"wallets_open_folder": "폴더 위치 열기", "wallets_open_folder": "폴더 위치 열기",
"wallets_open_inplace_tt": "이 지갑을 있는 자리에서 엽니다 — 데이터 디렉터리에 링크 (복사 없음)", "wallets_open_inplace_tt": "이 지갑을 있는 자리에서 엽니다 — 데이터 디렉터리에 링크 (복사 없음)",
"wallets_remove_folder": "이 폴더 스캔 중지",
"wallets_reveal": "폴더 열기", "wallets_reveal": "폴더 열기",
"wallets_scanned_folders": "스캔한 폴더:",
"wallets_sort_addresses": "주소", "wallets_sort_addresses": "주소",
"wallets_sort_asc": "오름차순 (오래된/적은/작은 순)", "wallets_sort_asc": "오름차순 (오래된/적은/작은 순)",
"wallets_sort_by": "정렬:", "wallets_sort_by": "정렬:",

View File

@@ -534,6 +534,7 @@
"market_btc_price": "PREÇO BTC", "market_btc_price": "PREÇO BTC",
"market_cap": "Capitalização", "market_cap": "Capitalização",
"market_cap_short": "Cap.", "market_cap_short": "Cap.",
"market_chart_loading": "Carregando histórico de preços",
"market_iv_1d": "1D", "market_iv_1d": "1D",
"market_iv_1h": "1H", "market_iv_1h": "1H",
"market_iv_1m": "1M", "market_iv_1m": "1M",
@@ -547,6 +548,8 @@
"market_price_loading": "Carregando dados de preço...", "market_price_loading": "Carregando dados de preço...",
"market_price_unavailable": "Dados de preço indisponíveis", "market_price_unavailable": "Dados de preço indisponíveis",
"market_refresh_price": "Atualizar dados de preço", "market_refresh_price": "Atualizar dados de preço",
"market_style_candle": "Mudar para velas",
"market_style_line": "Mudar para gráfico de linhas",
"market_trade_on": "Negociar no %s", "market_trade_on": "Negociar no %s",
"market_updated": "\\xc2\\xb7 Atualizado %s", "market_updated": "\\xc2\\xb7 Atualizado %s",
"market_vol_short": "Vol", "market_vol_short": "Vol",
@@ -1343,6 +1346,7 @@
"wallets_created": "criada", "wallets_created": "criada",
"wallets_creating": "Criando carteira — o nó será reiniciado…", "wallets_creating": "Criando carteira — o nó será reiniciado…",
"wallets_current": "atual", "wallets_current": "atual",
"wallets_empty_hint": "Verifique uma pasta para encontrar mais carteiras",
"wallets_exists": "Já existe uma carteira com esse nome.", "wallets_exists": "Já existe uma carteira com esse nome.",
"wallets_external_tt": "Fora do seu diretório de dados — Abrir o vincula no lugar (sem cópia).", "wallets_external_tt": "Fora do seu diretório de dados — Abrir o vincula no lugar (sem cópia).",
"wallets_folder_hint": "/caminho/para/pasta com arquivos .dat de carteira", "wallets_folder_hint": "/caminho/para/pasta com arquivos .dat de carteira",
@@ -1356,7 +1360,9 @@
"wallets_open_failed": "Não foi possível abrir esta carteira no lugar. Provavelmente está em uma unidade diferente do seu diretório de dados — mova-a para a mesma unidade (no Windows, ativar o Modo de Desenvolvedor também permite vincular entre unidades).", "wallets_open_failed": "Não foi possível abrir esta carteira no lugar. Provavelmente está em uma unidade diferente do seu diretório de dados — mova-a para a mesma unidade (no Windows, ativar o Modo de Desenvolvedor também permite vincular entre unidades).",
"wallets_open_folder": "Abrir local da pasta", "wallets_open_folder": "Abrir local da pasta",
"wallets_open_inplace_tt": "Abre esta carteira onde está — vinculada ao diretório de dados (sem cópia)", "wallets_open_inplace_tt": "Abre esta carteira onde está — vinculada ao diretório de dados (sem cópia)",
"wallets_remove_folder": "Parar de verificar esta pasta",
"wallets_reveal": "Mostrar pasta", "wallets_reveal": "Mostrar pasta",
"wallets_scanned_folders": "Pastas verificadas:",
"wallets_sort_addresses": "Endereços", "wallets_sort_addresses": "Endereços",
"wallets_sort_asc": "Crescente (mais antigo / menos / menor primeiro)", "wallets_sort_asc": "Crescente (mais antigo / menos / menor primeiro)",
"wallets_sort_by": "Ordenar:", "wallets_sort_by": "Ordenar:",

View File

@@ -534,6 +534,7 @@
"market_btc_price": "ЦЕНА BTC", "market_btc_price": "ЦЕНА BTC",
"market_cap": "Рыночная капитализация", "market_cap": "Рыночная капитализация",
"market_cap_short": "Кап.", "market_cap_short": "Кап.",
"market_chart_loading": "Загрузка истории цен",
"market_iv_1d": "1Д", "market_iv_1d": "1Д",
"market_iv_1h": "1Ч", "market_iv_1h": "1Ч",
"market_iv_1m": "1М", "market_iv_1m": "1М",
@@ -547,6 +548,8 @@
"market_price_loading": "Загрузка данных о ценах...", "market_price_loading": "Загрузка данных о ценах...",
"market_price_unavailable": "Данные о ценах недоступны", "market_price_unavailable": "Данные о ценах недоступны",
"market_refresh_price": "Обновить данные о ценах", "market_refresh_price": "Обновить данные о ценах",
"market_style_candle": "Переключить на свечи",
"market_style_line": "Переключить на линейный график",
"market_trade_on": "Торговать на %s", "market_trade_on": "Торговать на %s",
"market_updated": "\\xc2\\xb7 Обновлено %s", "market_updated": "\\xc2\\xb7 Обновлено %s",
"market_vol_short": "Объём", "market_vol_short": "Объём",
@@ -1343,6 +1346,7 @@
"wallets_created": "создан", "wallets_created": "создан",
"wallets_creating": "Создание кошелька — узел будет перезапущен…", "wallets_creating": "Создание кошелька — узел будет перезапущен…",
"wallets_current": "текущий", "wallets_current": "текущий",
"wallets_empty_hint": "Просканируйте папку, чтобы найти другие кошельки",
"wallets_exists": "Кошелёк с таким именем уже существует.", "wallets_exists": "Кошелёк с таким именем уже существует.",
"wallets_external_tt": "Вне каталога данных — «Открыть» создаёт ссылку на месте (без копирования).", "wallets_external_tt": "Вне каталога данных — «Открыть» создаёт ссылку на месте (без копирования).",
"wallets_folder_hint": "/путь/к/папке с файлами кошельков .dat", "wallets_folder_hint": "/путь/к/папке с файлами кошельков .dat",
@@ -1356,7 +1360,9 @@
"wallets_open_failed": "Не удалось открыть этот кошелёк на месте. Вероятно, он на другом диске, чем каталог данных — переместите его на тот же диск (в Windows включённый режим разработчика также позволяет создавать ссылки между дисками).", "wallets_open_failed": "Не удалось открыть этот кошелёк на месте. Вероятно, он на другом диске, чем каталог данных — переместите его на тот же диск (в Windows включённый режим разработчика также позволяет создавать ссылки между дисками).",
"wallets_open_folder": "Открыть расположение папки", "wallets_open_folder": "Открыть расположение папки",
"wallets_open_inplace_tt": "Открывает этот кошелёк на месте — по ссылке в каталоге данных (без копирования)", "wallets_open_inplace_tt": "Открывает этот кошелёк на месте — по ссылке в каталоге данных (без копирования)",
"wallets_remove_folder": "Больше не сканировать эту папку",
"wallets_reveal": "Открыть папку", "wallets_reveal": "Открыть папку",
"wallets_scanned_folders": "Просканированные папки:",
"wallets_sort_addresses": "Адреса", "wallets_sort_addresses": "Адреса",
"wallets_sort_asc": "По возрастанию (сначала старые / меньше / меньший)", "wallets_sort_asc": "По возрастанию (сначала старые / меньше / меньший)",
"wallets_sort_by": "Сортировка:", "wallets_sort_by": "Сортировка:",

View File

@@ -534,6 +534,7 @@
"market_btc_price": "BTC 价格", "market_btc_price": "BTC 价格",
"market_cap": "市值", "market_cap": "市值",
"market_cap_short": "市值", "market_cap_short": "市值",
"market_chart_loading": "正在加载价格历史",
"market_iv_1d": "1天", "market_iv_1d": "1天",
"market_iv_1h": "1时", "market_iv_1h": "1时",
"market_iv_1m": "1M", "market_iv_1m": "1M",
@@ -547,6 +548,8 @@
"market_price_loading": "正在加载价格数据...", "market_price_loading": "正在加载价格数据...",
"market_price_unavailable": "价格数据不可用", "market_price_unavailable": "价格数据不可用",
"market_refresh_price": "刷新价格数据", "market_refresh_price": "刷新价格数据",
"market_style_candle": "切换到蜡烛图",
"market_style_line": "切换到折线图",
"market_trade_on": "在 %s 交易", "market_trade_on": "在 %s 交易",
"market_updated": "\\xc2\\xb7 已更新 %s", "market_updated": "\\xc2\\xb7 已更新 %s",
"market_vol_short": "成交量", "market_vol_short": "成交量",
@@ -1343,6 +1346,7 @@
"wallets_created": "创建于", "wallets_created": "创建于",
"wallets_creating": "正在创建钱包——节点将重启…", "wallets_creating": "正在创建钱包——节点将重启…",
"wallets_current": "当前", "wallets_current": "当前",
"wallets_empty_hint": "扫描文件夹以查找更多钱包",
"wallets_exists": "已存在同名钱包。", "wallets_exists": "已存在同名钱包。",
"wallets_external_tt": "在数据目录之外 —「打开」会就地链接(不复制)。", "wallets_external_tt": "在数据目录之外 —「打开」会就地链接(不复制)。",
"wallets_folder_hint": "/含 .dat 钱包文件的文件夹路径", "wallets_folder_hint": "/含 .dat 钱包文件的文件夹路径",
@@ -1356,7 +1360,9 @@
"wallets_open_failed": "无法在原位置打开此钱包。它可能与数据目录位于不同的驱动器上 — 请将其移动到同一驱动器(在 Windows 上,启用开发者模式也可跨驱动器链接)。", "wallets_open_failed": "无法在原位置打开此钱包。它可能与数据目录位于不同的驱动器上 — 请将其移动到同一驱动器(在 Windows 上,启用开发者模式也可跨驱动器链接)。",
"wallets_open_folder": "打开文件夹位置", "wallets_open_folder": "打开文件夹位置",
"wallets_open_inplace_tt": "在原位置打开此钱包 — 链接到数据目录(不复制)", "wallets_open_inplace_tt": "在原位置打开此钱包 — 链接到数据目录(不复制)",
"wallets_remove_folder": "停止扫描此文件夹",
"wallets_reveal": "打开文件夹", "wallets_reveal": "打开文件夹",
"wallets_scanned_folders": "已扫描的文件夹:",
"wallets_sort_addresses": "地址", "wallets_sort_addresses": "地址",
"wallets_sort_asc": "升序(最早/最少/最小优先)", "wallets_sort_asc": "升序(最早/最少/最小优先)",
"wallets_sort_by": "排序:", "wallets_sort_by": "排序:",

View File

@@ -2232,6 +2232,46 @@ TRANSLATIONS = {
"ja": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。", "ja": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。",
"ko": "데이터 디렉터리 밖 — '열기'는 제자리에 링크합니다 (복사 없음)." "ko": "데이터 디렉터리 밖 — '열기'는 제자리에 링크합니다 (복사 없음)."
}, },
"wallets_scanned_folders": {
"es": "Carpetas escaneadas:", "de": "Durchsuchte Ordner:",
"fr": "Dossiers analysés :", "pt": "Pastas verificadas:",
"ru": "Просканированные папки:", "zh": "已扫描的文件夹:",
"ja": "スキャン対象フォルダー:", "ko": "스캔한 폴더:"
},
"wallets_remove_folder": {
"es": "Dejar de escanear esta carpeta", "de": "Diesen Ordner nicht mehr durchsuchen",
"fr": "Ne plus analyser ce dossier", "pt": "Parar de verificar esta pasta",
"ru": "Больше не сканировать эту папку", "zh": "停止扫描此文件夹",
"ja": "このフォルダーのスキャンを停止", "ko": "이 폴더 스캔 중지"
},
"wallets_empty_hint": {
"es": "Escanea una carpeta para encontrar más carteras",
"de": "Ordner durchsuchen, um weitere Wallets zu finden",
"fr": "Analysez un dossier pour trouver d'autres portefeuilles",
"pt": "Verifique uma pasta para encontrar mais carteiras",
"ru": "Просканируйте папку, чтобы найти другие кошельки",
"zh": "扫描文件夹以查找更多钱包",
"ja": "フォルダーをスキャンして他のウォレットを探す",
"ko": "폴더를 스캔하여 다른 지갑 찾기"
},
"market_chart_loading": {
"es": "Cargando historial de precios", "de": "Preisverlauf wird geladen",
"fr": "Chargement de l'historique des prix", "pt": "Carregando histórico de preços",
"ru": "Загрузка истории цен", "zh": "正在加载价格历史",
"ja": "価格履歴を読み込み中", "ko": "가격 기록 불러오는 중"
},
"market_style_line": {
"es": "Cambiar a gráfico de líneas", "de": "Zum Liniendiagramm wechseln",
"fr": "Passer au graphique en ligne", "pt": "Mudar para gráfico de linhas",
"ru": "Переключить на линейный график", "zh": "切换到折线图",
"ja": "折れ線チャートに切り替え", "ko": "선형 차트로 전환"
},
"market_style_candle": {
"es": "Cambiar a velas", "de": "Zu Kerzenchart wechseln",
"fr": "Passer aux chandeliers", "pt": "Mudar para velas",
"ru": "Переключить на свечи", "zh": "切换到蜡烛图",
"ja": "ローソク足に切り替え", "ko": "캔들차트로 전환"
},
} }
def main(): def main():

View File

@@ -335,6 +335,13 @@ public:
// Fetch historical USD price series (CoinGecko market_chart) that back the portfolio // Fetch historical USD price series (CoinGecko market_chart) that back the portfolio
// sparkline intervals; self-throttled to ~30 min. Safe to call every frame. // sparkline intervals; self-throttled to ~30 min. Safe to call every frame.
void refreshMarketChart(); 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();
// True while a market price-history fetch is in flight (CoinGecko aggregate OR per-exchange). The
// Market chart shows a loading indicator instead of the empty state during pair switches.
bool isMarketChartLoading() const { return chart_fetch_in_flight_ || exchange_chart_fetch_in_flight_; }
/// @brief Per-category refresh intervals, adjusted by active tab /// @brief Per-category refresh intervals, adjusted by active tab
using RefreshIntervals = services::NetworkRefreshService::Intervals; using RefreshIntervals = services::NetworkRefreshService::Intervals;
@@ -675,6 +682,11 @@ private:
bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied
bool exchanges_fetch_started_ = false; // once-per-session CoinGecko tickers fetch 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 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_; // "<identifier>:<BASE>/<QUOTE>" 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_; util::AsyncTaskManager async_tasks_;
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog

View File

@@ -50,6 +50,8 @@
#include "default_banlist_embedded.h" #include "default_banlist_embedded.h"
#include "util/amount_format.h" #include "util/amount_format.h"
#include "util/http_download.h" #include "util/http_download.h"
#include "data/exchange_info.h"
#include "data/exchange_candles.h"
#include "util/platform.h" #include "util/platform.h"
#include "util/perf_log.h" #include "util/perf_log.h"
#include "util/i18n.h" #include "util/i18n.h"
@@ -1768,6 +1770,79 @@ void App::refreshMarketData()
refreshPrice(); refreshPrice();
refreshExchanges(); refreshExchanges();
refreshMarketChart(); 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 = &reg[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 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 (!ohlcDaily.empty() || !ohlcIntra.empty()) {
// Derive close series (line + change%) from the OHLC (candles).
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;
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() void App::refreshMarketChart()

View File

@@ -285,6 +285,8 @@ bool Settings::load(const std::string& path)
loadScalar(j, "reduce_motion", reduce_motion_); loadScalar(j, "reduce_motion", reduce_motion_);
loadScalar(j, "selected_exchange", selected_exchange_); loadScalar(j, "selected_exchange", selected_exchange_);
loadScalar(j, "selected_pair", selected_pair_); loadScalar(j, "selected_pair", selected_pair_);
loadScalar(j, "chart_interval", chart_interval_);
loadScalar(j, "chart_style", chart_style_);
loadScalar(j, "pool_url", pool_url_); loadScalar(j, "pool_url", pool_url_);
// Migrate old default pool URL that was missing the stratum port // Migrate old default pool URL that was missing the stratum port
if (pool_url_ == "pool.dragonx.is") pool_url_ = "pool.dragonx.is:3433"; if (pool_url_ == "pool.dragonx.is") pool_url_ = "pool.dragonx.is:3433";
@@ -486,6 +488,8 @@ bool Settings::save(const std::string& path)
j["reduce_motion"] = reduce_motion_; j["reduce_motion"] = reduce_motion_;
j["selected_exchange"] = selected_exchange_; j["selected_exchange"] = selected_exchange_;
j["selected_pair"] = selected_pair_; j["selected_pair"] = selected_pair_;
j["chart_interval"] = chart_interval_;
j["chart_style"] = chart_style_;
j["pool_url"] = pool_url_; j["pool_url"] = pool_url_;
j["pool_algo"] = pool_algo_; j["pool_algo"] = pool_algo_;
j["pool_worker"] = pool_worker_; j["pool_worker"] = pool_worker_;

View File

@@ -370,6 +370,10 @@ public:
void setSelectedExchange(const std::string& v) { selected_exchange_ = v; } void setSelectedExchange(const std::string& v) { selected_exchange_ = v; }
std::string getSelectedPair() const { return selected_pair_; } std::string getSelectedPair() const { return selected_pair_; }
void setSelectedPair(const std::string& v) { selected_pair_ = v; } void setSelectedPair(const std::string& v) { selected_pair_ = v; }
int getChartInterval() const { return chart_interval_; } // Market chart range 0=Live..4=1M
void setChartInterval(int v) { chart_interval_ = v; }
int getChartStyle() const { return chart_style_; } // 0 = line, 1 = candlestick
void setChartStyle(int v) { chart_style_ = v; }
// Pool mining // Pool mining
std::string getPoolUrl() const { return pool_url_; } std::string getPoolUrl() const { return pool_url_; }
@@ -470,7 +474,7 @@ private:
bool gradient_background_ = false; bool gradient_background_ = false;
#ifdef _WIN32 #ifdef _WIN32
float ui_opacity_ = 0.50f; // Card/sidebar opacity (0.31.0, 1.0 = opaque) float ui_opacity_ = 0.50f; // Card/sidebar opacity (0.31.0, 1.0 = opaque)
float window_opacity_ = 0.75f; // Background alpha (0.31.0, <1 = desktop visible) float window_opacity_ = 0.90f; // Background alpha (0.31.0, <1 = desktop visible)
#else #else
float ui_opacity_ = 1.0f; // Mac/Linux: default fully opaque float ui_opacity_ = 1.0f; // Mac/Linux: default fully opaque
float window_opacity_ = 1.0f; // Mac/Linux: default fully opaque float window_opacity_ = 1.0f; // Mac/Linux: default fully opaque
@@ -524,6 +528,8 @@ private:
bool reduce_motion_ = false; bool reduce_motion_ = false;
std::string selected_exchange_ = "Nonkyc.io"; std::string selected_exchange_ = "Nonkyc.io";
std::string selected_pair_ = "DRGX/USDT"; std::string selected_pair_ = "DRGX/USDT";
int chart_interval_ = 4; // Market chart range (0=Live 1=1H 2=1D 3=1W 4=1M)
int chart_style_ = 1; // Market chart style (0=line, 1=candlestick)
// Pool mining // Pool mining
std::string pool_url_ = "pool.dragonx.is:3433"; std::string pool_url_ = "pool.dragonx.is:3433";

44
src/data/candle.h Normal file
View 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

117
src/data/exchange_candles.h Normal file
View File

@@ -0,0 +1,117 @@
#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 <algorithm>
#include <ctime>
#include <string>
#include <utility>
#include <vector>
#include <nlohmann/json.hpp>
#include "candle.h"
namespace dragonx {
namespace data {
using PricePoint = std::pair<std::time_t, double>; // (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<double>();
if (v.is_string()) { try { return std::stod(v.get<std::string>()); } catch (...) { return 0.0; } }
return 0.0;
}
} // namespace detail
// Parse the venue's candle response into ascending OHLC candles. Empty on failure.
inline std::vector<Candle> parseExchangeOHLC(const std::string& identifier, const std::string& body) {
std::vector<Candle> 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;
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":..}, ...]}
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;
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 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<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;
}
} // namespace data
} // namespace dragonx

View File

@@ -22,14 +22,14 @@ const std::vector<ExchangeInfo>& getExchangeRegistry()
"Nonkyc.io", "Nonkyc.io",
"https://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", "OurBit",
"https://www.ourbit.com", "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,19 @@ std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& body)
if (!t.is_object()) continue; if (!t.is_object()) continue;
const std::string base = t.value("base", std::string{}); const std::string base = t.value("base", std::string{});
const std::string target = t.value("target", std::string{}); const std::string target = t.value("target", std::string{});
std::string market; std::string market, identifier;
if (t.contains("market") && t["market"].is_object()) if (t.contains("market") && t["market"].is_object()) {
market = t["market"].value("name", std::string{}); 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{}); const std::string tradeUrl = t.value("trade_url", std::string{});
if (base.empty() || target.empty() || market.empty()) continue; if (base.empty() || target.empty() || market.empty()) continue;
double lastUsd = 0.0; // this venue's current USD price + 24h volume — differ per exchange
if (t.contains("converted_last") && t["converted_last"].is_object())
lastUsd = t["converted_last"].value("usd", 0.0);
double volumeUsd = 0.0;
if (t.contains("converted_volume") && t["converted_volume"].is_object())
volumeUsd = t["converted_volume"].value("usd", 0.0);
auto it = byName.find(market); auto it = byName.find(market);
if (it == byName.end()) { if (it == byName.end()) {
@@ -71,7 +79,7 @@ std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& body)
exchanges.push_back(ExchangeInfo{market, originOf(tradeUrl), {}}); exchanges.push_back(ExchangeInfo{market, originOf(tradeUrl), {}});
it = byName.find(market); it = byName.find(market);
} }
ExchangePair pair{base, target, base + "/" + target, tradeUrl}; ExchangePair pair{base, target, base + "/" + target, tradeUrl, identifier, lastUsd, volumeUsd};
// Skip duplicate pairs on the same exchange. // Skip duplicate pairs on the same exchange.
bool dup = false; bool dup = false;
for (const auto& p : exchanges[it->second].pairs) for (const auto& p : exchanges[it->second].pairs)

View File

@@ -18,6 +18,9 @@ struct ExchangePair {
std::string quote; ///< e.g. "BTC" std::string quote; ///< e.g. "BTC"
std::string displayName; ///< e.g. "DRGX/BTC" std::string displayName; ///< e.g. "DRGX/BTC"
std::string tradeUrl; ///< Link to the exchange pair page 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
double volumeUsd = 0.0; ///< This venue's 24h volume in USD (CoinGecko converted_volume.usd); 0 if unknown
}; };
/** /**

View File

@@ -81,11 +81,15 @@ inline std::vector<std::pair<std::time_t, double>> chartSeries(const MarketInfo&
for (const auto& s : src) if (s.first >= cutoff) out.push_back(s); for (const auto& s : src) if (s.first >= cutoff) out.push_back(s);
return out; 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) { switch (interval) {
case 1: { auto v = lastWindow(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } // 1H case 1: { auto v = lastWindow(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 2: { auto v = lastWindow(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 3: { auto v = lastWindow(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 4: { auto v = lastWindow(daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M
default: break; default: break;
} }
std::vector<std::pair<std::time_t, double>> out; std::vector<std::pair<std::time_t, double>> out;
@@ -95,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

View File

@@ -12,6 +12,7 @@
#include <utility> #include <utility>
#include "exchange_info.h" #include "exchange_info.h"
#include "candle.h"
namespace dragonx { namespace dragonx {
@@ -175,6 +176,18 @@ struct MarketInfo {
std::chrono::steady_clock::time_point chart_last_fetch_time{}; std::chrono::steady_clock::time_point chart_last_fetch_time{};
bool chart_loaded = false; 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<std::pair<std::time_t, double>> exchange_chart_intraday;
std::vector<std::pair<std::time_t, double>> 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<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).
std::vector<data::ExchangeInfo> exchanges; std::vector<data::ExchangeInfo> exchanges;

View File

@@ -388,14 +388,14 @@ void AcrylicMaterial::setQuality(AcrylicQuality quality)
} }
} }
void AcrylicMaterial::applyBlur(float radius) void AcrylicMaterial::applyBlur(float radius, bool ignoreMultiplier)
{ {
if (!blurBuffers_.isValid() || !blurShader_.isValid()) { if (!blurBuffers_.isValid() || !blurShader_.isValid()) {
return; return;
} }
// Apply blur radius multiplier from settings // Apply the user's blur-strength multiplier — unless the caller wants an absolute radius.
float scaledRadius = radius * settings_.blurRadiusMultiplier; float scaledRadius = ignoreMultiplier ? radius : radius * settings_.blurRadiusMultiplier;
// Skip blur entirely when multiplier is at or near zero — show sharp background // Skip blur entirely when multiplier is at or near zero — show sharp background
if (scaledRadius < 0.5f) { if (scaledRadius < 0.5f) {
@@ -566,7 +566,7 @@ void AcrylicMaterial::drawRect(ImDrawList* drawList, const ImVec2& pMin, const I
} }
// Apply blur to captured background // Apply blur to captured background
applyBlur(params.blurRadius); applyBlur(params.blurRadius, params.absoluteBlurRadius);
// Calculate UV coordinates for sampling the blur FBO texture. // Calculate UV coordinates for sampling the blur FBO texture.
// With multi-viewport enabled, ImGui draw coordinates are in // With multi-viewport enabled, ImGui draw coordinates are in
@@ -1274,11 +1274,11 @@ void AcrylicMaterial::captureLiveFramebuffer()
blurCacheValid_ = false; blurCacheValid_ = false;
} }
void AcrylicMaterial::applyBlur(float radius) void AcrylicMaterial::applyBlur(float radius, bool ignoreMultiplier)
{ {
if (!dx_blurSRV_[0] || !dx_blurPS_ || !dx_context_) return; if (!dx_blurSRV_[0] || !dx_blurPS_ || !dx_context_) return;
float scaledRadius = radius * settings_.blurRadiusMultiplier; float scaledRadius = ignoreMultiplier ? radius : radius * settings_.blurRadiusMultiplier;
if (blurCacheValid_ && std::abs(scaledRadius - lastBlurRadius_) < 0.1f) return; if (blurCacheValid_ && std::abs(scaledRadius - lastBlurRadius_) < 0.1f) return;
int passes = 1; int passes = 1;
@@ -1540,7 +1540,7 @@ void AcrylicMaterial::drawRect(ImDrawList* drawList, const ImVec2& pMin, const I
} }
// Run DX11 blur pipeline // Run DX11 blur pipeline
applyBlur(params.blurRadius); applyBlur(params.blurRadius, params.absoluteBlurRadius);
// DX11 UV: top-left = (0,0), bottom-right = (1,1) — same as ImGui // DX11 UV: top-left = (0,0), bottom-right = (1,1) — same as ImGui
// With multi-viewport, pMin/pMax are in OS screen space; subtract // With multi-viewport, pMin/pMax are in OS screen space; subtract
@@ -1704,7 +1704,7 @@ void AcrylicMaterial::resize(int, int) {}
void AcrylicMaterial::captureBackground() {} void AcrylicMaterial::captureBackground() {}
void AcrylicMaterial::captureBackgroundDirect() {} void AcrylicMaterial::captureBackgroundDirect() {}
void AcrylicMaterial::captureLiveFramebuffer() {} void AcrylicMaterial::captureLiveFramebuffer() {}
void AcrylicMaterial::applyBlur(float) {} void AcrylicMaterial::applyBlur(float, bool) {}
ImTextureID AcrylicMaterial::getBlurredTexture() const { return 0; } ImTextureID AcrylicMaterial::getBlurredTexture() const { return 0; }
ImTextureID AcrylicMaterial::getNoiseTexture() const { return 0; } ImTextureID AcrylicMaterial::getNoiseTexture() const { return 0; }
void AcrylicMaterial::refreshCapabilities() {} void AcrylicMaterial::refreshCapabilities() {}

View File

@@ -40,6 +40,11 @@ struct AcrylicParams {
// Blur radius in pixels (typical: 20-60) // Blur radius in pixels (typical: 20-60)
float blurRadius = 30.0f; float blurRadius = 30.0f;
// When true, blurRadius is used AS-IS — NOT scaled by the user's blur-strength slider
// (blurRadiusMultiplier). The modal backdrop sets this so its blur is a fixed value, independent
// of the global acrylic slider.
bool absoluteBlurRadius = false;
// Noise texture opacity (typical: 0.02-0.04) // Noise texture opacity (typical: 0.02-0.04)
float noiseOpacity = 0.02f; float noiseOpacity = 0.02f;
@@ -341,7 +346,7 @@ private:
/** /**
* @brief Apply blur passes to captured content * @brief Apply blur passes to captured content
*/ */
void applyBlur(float radius); void applyBlur(float radius, bool ignoreMultiplier = false);
/** /**
* @brief Composite final acrylic appearance * @brief Composite final acrylic appearance

View File

@@ -742,7 +742,8 @@ inline void DrawFullWindowBlurBackdrop(ImDrawList* dl, const ImVec2& pMin, const
// panel skips acrylic (IsFullWindowBlurOverlayActive), so the backdrop is the SOLE applyBlur // panel skips acrylic (IsFullWindowBlurOverlayActive), so the backdrop is the SOLE applyBlur
// caller — no other radius contends for the shared, radius-keyed blur cache. // caller — no other radius contends for the shared, radius-keyed blur cache.
auto params = GetCurrentAcrylicTheme().card; auto params = GetCurrentAcrylicTheme().card;
params.blurRadius = 96.0f; // strong full-window blur (deeper than panels) params.blurRadius = 120.0f; // strong full-window blur (deeper than panels)
params.absoluteBlurRadius = true; // fixed modal blur — independent of the user's acrylic slider
params.fallbackColor.w = 1.0f; // full-strength blur so the live content reads params.fallbackColor.w = 1.0f; // full-strength blur so the live content reads
effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax, params, 0.0f); effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax, params, 0.0f);
// Dim over the blur so the floating (card-less) modal reads as foreground. On DARK themes the // Dim over the blur so the floating (card-less) modal reads as foreground. On DARK themes the
@@ -751,9 +752,9 @@ inline void DrawFullWindowBlurBackdrop(ImDrawList* dl, const ImVec2& pMin, const
// light themes get a neutral dark veil instead, which also makes the modal's fields/buttons stand // light themes get a neutral dark veil instead, which also makes the modal's fields/buttons stand
// out from the (now slightly greyed) surround. Both a touch stronger than before (was alpha 70). // out from the (now slightly greyed) surround. Both a touch stronger than before (was alpha 70).
if (IsLightTheme()) if (IsLightTheme())
dl->AddRectFilled(pMin, pMax, IM_COL32(16, 18, 24, 56)); // neutral dark veil anchors on white dl->AddRectFilled(pMin, pMax, IM_COL32(16, 18, 24, 200)); // neutral dark veil anchors on white
else else
dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 90)); // theme-tinted darken, stronger dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 120)); // theme-tinted darken, stronger
} }
} }

View File

@@ -44,6 +44,7 @@ struct MarketViewState {
int pairIdx = 0; int pairIdx = 0;
bool stateLoaded = false; bool stateLoaded = false;
int chartInterval = 4; // main chart range: 0=Live 1=1H 2=1D 3=1W 4=1M (default 1M) int chartInterval = 4; // main chart range: 0=Live 1=1H 2=1D 3=1W 4=1M (default 1M)
int chartStyle = 1; // 0 = line, 1 = candlestick (only applies when per-exchange OHLC exists)
}; };
static MarketViewState s_mkt; static MarketViewState s_mkt;
@@ -75,6 +76,12 @@ static void LoadMarketState(config::Settings* settings, const std::vector<data::
break; break;
} }
} }
// Restore the chart range + style (validated).
int iv = settings->getChartInterval();
if (iv >= 0 && iv <= 4) s_mkt.chartInterval = iv;
int st = settings->getChartStyle();
if (st == 0 || st == 1) s_mkt.chartStyle = st;
} }
// Helper: format compact currency // Helper: format compact currency
@@ -1202,6 +1209,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;
@@ -1240,10 +1248,17 @@ static void mktDrawPriceHero(const MktCtx& cx)
float cx0 = cardMin.x + Layout::spacingLg(); float cx0 = cardMin.x + Layout::spacingLg();
float cy = cardMin.y + 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) ---- // ---- HERO PRICE (large, prominent) ----
ImFont* h3 = Type().h3(); ImFont* h3 = Type().h3();
std::string priceStr = FormatPrice(market.price_usd); std::string priceStr = FormatPrice(heroPrice);
ImU32 priceCol = Success(); ImU32 priceCol = Success();
DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx0, cy), priceCol, priceStr.c_str()); DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx0, cy), priceCol, priceStr.c_str());
@@ -1398,12 +1413,38 @@ static void mktDrawPriceChart(const MktCtx& cx)
ImGui::PushID(9100 + b); ImGui::PushID(9100 + b);
if (ImGui::InvisibleButton("##civ", ImVec2(bw, pillH))) { if (ImGui::InvisibleButton("##civ", ImVec2(bw, pillH))) {
s_mkt.chartInterval = kIvs[b].iv; s_mkt.chartInterval = kIvs[b].iv;
if (app->settings()) { app->settings()->setChartInterval(kIvs[b].iv); app->settings()->save(); }
} }
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PopID(); ImGui::PopID();
bx += bw + Layout::spacingXs(); bx += bw + Layout::spacingXs();
} }
// Line/candle toggle — only when the selected range has per-exchange candles (the aggregate /
// Live view is line-only, so the toggle is hidden there).
if (cx.chartCandles && cx.chartCandles->size() >= 2) {
bx += Layout::spacingSm();
ImFont* icoF = material::Typography::instance().iconSmall();
const bool isCandle = (s_mkt.chartStyle == 1);
const char* styleIcon = isCandle ? ICON_MD_CANDLESTICK_CHART : ICON_MD_SHOW_CHART;
ImVec2 tmn(bx, rowTop), tmx(bx + pillH, rowTop + pillH);
bool thov = material::IsRectHovered(tmn, tmx);
if (thov) { dl->AddRectFilled(tmn, tmx, IM_COL32(255, 255, 255, 20), 4.0f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
ImVec2 tiSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, styleIcon);
dl->AddText(icoF, icoF->LegacySize,
ImVec2(tmn.x + (pillH - tiSz.x) * 0.5f, tmn.y + (pillH - tiSz.y) * 0.5f),
thov ? OnSurface() : OnSurfaceMedium(), styleIcon);
ImGui::SetCursorScreenPos(tmn);
if (ImGui::InvisibleButton("##ChartStyle", ImVec2(pillH, pillH))) {
s_mkt.chartStyle = isCandle ? 0 : 1;
if (app->settings()) { app->settings()->setChartStyle(s_mkt.chartStyle); app->settings()->save(); }
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR(isCandle ? "market_style_line" : "market_style_candle"));
bx += pillH;
}
// Refresh button (far right). // Refresh button (far right).
float rEdge = chartMax.x - chartPad; float rEdge = chartMax.x - chartPad;
ImFont* iconSmall = material::Typography::instance().iconSmall(); ImFont* iconSmall = material::Typography::instance().iconSmall();
@@ -1433,8 +1474,15 @@ static void mktDrawPriceChart(const MktCtx& cx)
sxr -= Layout::spacingMd(); sxr -= Layout::spacingMd();
}; };
if (market.price_usd > 0) { if (market.price_usd > 0) {
// Market cap is coin-wide (stays aggregate); 24h volume is per-venue — show the SELECTED
// exchange's own volume when we have it, else the CoinGecko aggregate.
drawStat(TR("market_cap_short"), FormatCompactUSD(market.market_cap)); drawStat(TR("market_cap_short"), FormatCompactUSD(market.market_cap));
drawStat(TR("market_vol_short"), FormatCompactUSD(market.volume_24h)); double vol = market.volume_24h;
const auto& exch = *cx.currentExchange;
if (!exch.pairs.empty() && s_mkt.pairIdx < (int)exch.pairs.size()
&& exch.pairs[s_mkt.pairIdx].volumeUsd > 0.0)
vol = exch.pairs[s_mkt.pairIdx].volumeUsd;
drawStat(TR("market_vol_short"), FormatCompactUSD(vol));
} }
} }
@@ -1449,10 +1497,19 @@ 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 // Candlesticks when per-exchange OHLC exists AND the user hasn't switched to the line view.
double yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end()); const bool hasCandles = candles.size() >= 2 && s_mkt.chartStyle == 1;
double yMax = *std::max_element(s_mkt.history.begin(), s_mkt.history.end()); 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; } 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;
@@ -1485,24 +1542,78 @@ static void mktDrawPriceChart(const MktCtx& cx)
ImU32 lineCol = WithAlpha(dirCol, 220); ImU32 lineCol = WithAlpha(dirCol, 220);
ImU32 dotCol = dirCol; ImU32 dotCol = dirCol;
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);
}
// Hover: highlight the candle under the cursor + an OHLC readout (date + open/high/low/close).
ImVec2 mp = ImGui::GetIO().MousePos;
if (mp.x >= plotLeft && mp.x <= plotRight && mp.y >= plotTop && mp.y <= plotBottom) {
int hi = (int)((mp.x - plotLeft) / slotW);
if (hi < 0) hi = 0;
if (hi >= cn) hi = cn - 1;
const auto& hc = candles[hi];
const float hx = plotLeft + ((float)hi + 0.5f) * slotW;
dl->AddRectFilled(ImVec2(hx - slotW * 0.5f, plotTop), ImVec2(hx + slotW * 0.5f, plotBottom),
IM_COL32(255, 255, 255, 12));
dl->AddLine(ImVec2(hx, plotTop), ImVec2(hx, plotBottom), IM_COL32(255, 255, 255, 45), 1.0f);
char when[40] = "";
std::time_t tt = hc.time;
if (std::tm* tmv = std::localtime(&tt))
std::strftime(when, sizeof(when), s_mkt.chartInterval <= 2 ? "%b %d %H:%M" : "%b %d, %Y", tmv);
char l2[80], l3[80];
snprintf(l2, sizeof(l2), "O %s H %s", FormatPrice(hc.open).c_str(), FormatPrice(hc.high).c_str());
snprintf(l3, sizeof(l3), "L %s C %s", FormatPrice(hc.low).c_str(), FormatPrice(hc.close).c_str());
const float lh = capFont->LegacySize;
const float pad = Layout::spacingSm();
const float gap = 3.0f * mktDp;
float tw = std::max(capFont->CalcTextSizeA(lh, FLT_MAX, 0, when).x,
std::max(capFont->CalcTextSizeA(lh, FLT_MAX, 0, l2).x,
capFont->CalcTextSizeA(lh, FLT_MAX, 0, l3).x));
float th = lh * 3 + gap * 2 + pad * 2;
float tipX = hx + 10.0f * mktDp;
if (tipX + tw + pad * 2 > plotRight) tipX = hx - tw - pad * 2 - 10.0f * mktDp;
tipX = std::max(plotLeft, tipX);
float tipY = plotTop + 4.0f * mktDp;
ImVec2 tMin(tipX, tipY), tMax(tipX + tw + pad * 2, tipY + th);
dl->AddRectFilled(tMin, tMax, IM_COL32(20, 20, 30, 235), 4.0f);
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f);
ImU32 cCol = (hc.close >= hc.open) ? Success() : Error();
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad), OnSurface(), when);
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + lh + gap), cCol, l2);
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + 2 * lh + 2 * gap), cCol, l3);
}
} else {
for (size_t i = 0; i < n; i++) { for (size_t i = 0; i < n; i++) {
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f; float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
float x = plotLeft + t * plotW; points[i] = ImVec2(plotLeft + t * plotW, yOf(s_mkt.history[i]));
float y = plotBottom - (float)((s_mkt.history[i] - yMin) / (yMax - yMin)) * plotH;
points[i] = ImVec2(x, y);
} }
// Flat translucent area fill under the curve (matches the portfolio group sparklines). // Flat translucent area fill under the curve (matches the portfolio group sparklines).
for (size_t i = 0; i < n; i++) dl->PathLineTo(points[i]); for (size_t i = 0; i < n; i++) dl->PathLineTo(points[i]);
dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom)); dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
dl->PathLineTo(ImVec2(points[0].x, plotBottom)); dl->PathLineTo(ImVec2(points[0].x, plotBottom));
dl->PathFillConcave(WithAlpha(dirCol, 28)); dl->PathFillConcave(WithAlpha(dirCol, 28));
// Line (no per-point dots — a clean curve). // 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); 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). // High/low price labels at the displayed range's extremes (no dot markers). Line only — the
if (n >= 3) { // 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 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) {
@@ -1544,9 +1655,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;
@@ -1598,12 +1709,29 @@ static void mktDrawPriceChart(const MktCtx& cx)
} }
} else { } else {
// Empty state, centered in the plot area (the interval strip + refresh are already drawn). // Empty state, centered in the plot area (the interval strip + refresh are already drawn).
const float dp = Layout::dpiScale();
const float ecx = chartMin.x + availWidth * 0.5f;
const float ecy = (plotTop + plotBottom) * 0.5f;
if (app->isMarketChartLoading()) {
// A fetch is in flight — e.g. just switched exchange pairs. Show a spinner + animated label
// instead of "no history", since the data is on its way.
const float r = 10.0f * dp;
const float t = (float)ImGui::GetTime() * 4.5f; // rotate ~0.7 rev/s
dl->PathArcTo(ImVec2(ecx, ecy - 8.0f * dp), r, t, t + IM_PI * 1.5f, 24);
dl->PathStroke(OnSurfaceMedium(), false, 2.5f * dp);
char lb[80];
snprintf(lb, sizeof(lb), "%s%s", TR("market_chart_loading"), LoadingDots());
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, lb);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(ecx - ts.x * 0.5f, ecy + 10.0f * dp), OnSurfaceMedium(), lb);
} else {
const char* msg = TR("market_no_history"); const char* msg = TR("market_no_history");
ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg); ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
dl->AddText(sub1, sub1->LegacySize, dl->AddText(sub1, sub1->LegacySize,
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, (plotTop + plotBottom) * 0.5f - ts.y * 0.5f), ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, ecy - ts.y * 0.5f),
OnSurfaceDisabled(), msg); OnSurfaceDisabled(), msg);
} }
}
ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH)); ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH));
ImGui::Dummy(ImVec2(availWidth, 0)); ImGui::Dummy(ImVec2(availWidth, 0));
@@ -1847,7 +1975,8 @@ void RenderMarketTab(App* app)
float mktDp = Layout::dpiScale(); float mktDp = Layout::dpiScale();
// -- Compact price chart: a modest responsive height, no longer stretched to fill the tab. -- // -- Compact price chart: a modest responsive height, no longer stretched to fill the tab. --
float chartH = std::max(110.0f * vs, std::min(chartElem.height * vs, marketAvail.y * 0.22f)); // ~50% taller than before (floor / desired / viewport-cap all scaled) — a roomier price chart.
float chartH = std::max(165.0f * vs, std::min(chartElem.height * 1.5f * vs, marketAvail.y * 0.33f));
// -- Hero header: size to the ACTUAL content (price row + separator gap + stats row) so the // -- Hero header: size to the ACTUAL content (price row + separator gap + stats row) so the
// chart starts right after "24H VOLUME"/"Market Cap" instead of below a reserved empty band. -- // chart starts right after "24H VOLUME"/"Market Cap" instead of below a reserved empty band. --
@@ -1896,6 +2025,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)
@@ -1917,7 +2048,7 @@ void RenderMarketTab(App* app)
MktCtx mktc{ MktCtx mktc{
app, &currentExchange, app, &currentExchange,
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);

View File

@@ -71,7 +71,9 @@ public:
const int kMaxVisibleRows = 7; const int kMaxVisibleRows = 7;
ImFont* nameFont = Type().subtitle1(); ImFont* nameFont = Type().subtitle1();
ImFont* metaFont = Type().caption(); ImFont* metaFont = Type().caption();
const int visRows = std::min(std::max(1, (int)s_rows.size()), kMaxVisibleRows); // The list is always sized to its MAX height (kMaxVisibleRows) for a consistent modal size — it
// does not shrink to fit a few wallets; fewer rows leave empty space, more than 7 scroll.
const int visRows = kMaxVisibleRows;
const float cardPadY = Layout::spacingMd(); // roomier cards (was spacingSm) const float cardPadY = Layout::spacingMd(); // roomier cards (was spacingSm)
const float walRowH = cardPadY * 2.0f + nameFont->LegacySize + Layout::spacingSm() + metaFont->LegacySize; const float walRowH = cardPadY * 2.0f + nameFont->LegacySize + Layout::spacingSm() + metaFont->LegacySize;
const float cardGap = Layout::spacingMd(); // more breathing room between wallet cards const float cardGap = Layout::spacingMd(); // more breathing room between wallet cards
@@ -81,9 +83,14 @@ public:
const float ctrlRow = ImGui::GetFrameHeightWithSpacing(); const float ctrlRow = ImGui::GetFrameHeightWithSpacing();
const float capRow = Type().caption()->LegacySize + style.ItemSpacing.y; const float capRow = Type().caption()->LegacySize + style.ItemSpacing.y;
const int numScanFolders = app ? (int)app->walletIndex().extraFolders().size() : 0;
const float foldersH = numScanFolders > 0
? (capRow + (float)numScanFolders * ctrlRow + Layout::spacingXs()) // "Scanned folders:" + one row each
: 0.0f;
const float belowH = 4.0f * Layout::spacingSm() const float belowH = 4.0f * Layout::spacingSm()
+ (capRow + ctrlRow) // Create label + input row + (capRow + ctrlRow) // Create label + input row
+ ctrlRow // full-width "scan folder" button + ctrlRow // full-width "scan folder" button
+ foldersH // scanned-folders manager
+ (style.ItemSpacing.y + 1.0f) // separator + (style.ItemSpacing.y + 1.0f) // separator
+ ctrlRow // footer buttons + ctrlRow // footer buttons
+ 6.0f * style.ItemSpacing.y; // uncounted inter-item gaps + 6.0f * style.ItemSpacing.y; // uncounted inter-item gaps
@@ -379,6 +386,28 @@ public:
ImGui::Dummy(ImVec2(rowW, walRowH)); ImGui::Dummy(ImVec2(rowW, walRowH));
if (i + 1 < s_rows.size()) ImGui::Dummy(ImVec2(rowW, cardGap)); if (i + 1 < s_rows.size()) ImGui::Dummy(ImVec2(rowW, cardGap));
} }
// Empty-state nudge: the list sits at a fixed max height, so a handful of wallets leave blank
// space below. When there's real room to spare, fill it with a subtle centered hint (a folder
// glyph + one line) instead of dead space; it's purely decorative — the actions live below.
{
const float remainY = ImGui::GetContentRegionAvail().y;
if (remainY > walRowH * 1.6f) {
ImDrawList* wdl = ImGui::GetWindowDrawList();
ImFont* hIco = Type().iconLarge();
ImFont* hTxt = Type().caption();
const char* icon = ICON_MD_CREATE_NEW_FOLDER;
const char* hint = TR("wallets_empty_hint");
const ImU32 col = OnSurfaceDisabled();
const ImVec2 is = hIco->CalcTextSizeA(hIco->LegacySize, FLT_MAX, 0, icon);
const ImVec2 ts = hTxt->CalcTextSizeA(hTxt->LegacySize, FLT_MAX, 0, hint);
const float gap = Layout::spacingXs();
const ImVec2 o = ImGui::GetCursorScreenPos();
const float cx = o.x + ImGui::GetContentRegionAvail().x * 0.5f;
const float top = o.y + (remainY - (is.y + gap + ts.y)) * 0.5f;
wdl->AddText(hIco, hIco->LegacySize, ImVec2(cx - is.x * 0.5f, top), col, icon);
wdl->AddText(hTxt, hTxt->LegacySize, ImVec2(cx - ts.x * 0.5f, top + is.y + gap), col, hint);
}
}
ImGui::PopStyleVar(); // ItemSpacing ImGui::PopStyleVar(); // ItemSpacing
ImGui::EndChild(); ImGui::EndChild();
@@ -422,6 +451,51 @@ public:
}); });
} }
// ---- Manage scanned folders: list each user-added folder with a control to stop scanning it.
// (The datadir is always scanned and isn't listed here — only the folders the user added.) -----
{
const auto& scanFolders = app->walletIndex().extraFolders();
if (!scanFolders.empty()) {
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_scanned_folders"));
ImFont* pf = Type().caption();
const float bh2 = ImGui::GetFrameHeight();
// Front-elide a long path so its identifying tail (the leaf folder) stays visible.
auto elideFront = [&](std::string s, float maxW) {
auto w = [&](const std::string& t){ return pf->CalcTextSizeA(pf->LegacySize, FLT_MAX, 0, t.c_str()).x; };
if (w(s) <= maxW) return s;
while (s.size() > 1 && w("\xE2\x80\xA6" + s) > maxW) {
s.erase(s.begin());
while (!s.empty() && (static_cast<unsigned char>(s.front()) & 0xC0) == 0x80) s.erase(s.begin());
}
return "\xE2\x80\xA6" + s;
};
std::string toRemove;
for (const auto& folder : scanFolders) {
ImGui::PushID(folder.c_str());
IconButtonStyle rm;
rm.color = OnSurfaceMedium();
rm.hoverColor = Error();
rm.hoverBg = StateHover();
rm.bgRounding = 4.0f * dp;
rm.tooltip = TR("wallets_remove_folder");
if (IconButton("##rmScanFolder", ICON_MD_CLOSE, Type().iconSmall(), ImVec2(bh2, bh2), rm))
toRemove = folder;
ImGui::SameLine(0, Layout::spacingSm());
ImGui::AlignTextToFramePadding();
const float textW = listW - bh2 - 2.0f * Layout::spacingSm();
Type().textColored(TypeStyle::Caption, OnSurface(), elideFront(folder, textW).c_str());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", folder.c_str());
ImGui::PopID();
}
if (!toRemove.empty()) {
app->walletIndex().removeExtraFolder(toRemove);
app->walletIndex().save();
s_needScan = true; // re-scan so wallets from the dropped folder disappear
}
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Separator(); ImGui::Separator();
// ---- Refresh: icon-only, de-emphasized next to the text actions ----------------------- // ---- Refresh: icon-only, de-emphasized next to the text actions -----------------------

View File

@@ -287,6 +287,9 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_never"] = "Never opened"; strings_["wallets_never"] = "Never opened";
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Open links it in place (no copy)."; strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Open links it in place (no copy).";
strings_["wallets_scan_folder"] = "Scan another folder for wallets\xE2\x80\xA6"; strings_["wallets_scan_folder"] = "Scan another folder for wallets\xE2\x80\xA6";
strings_["wallets_scanned_folders"] = "Scanned folders:";
strings_["wallets_remove_folder"] = "Stop scanning this folder";
strings_["wallets_empty_hint"] = "Scan a folder to find more wallets";
strings_["wallets_folder_invalid"] = "That folder doesn't exist."; strings_["wallets_folder_invalid"] = "That folder doesn't exist.";
strings_["wallets_new_label"] = "Create a new wallet:"; strings_["wallets_new_label"] = "Create a new wallet:";
strings_["wallets_new_hint"] = "Name (e.g. savings)"; strings_["wallets_new_hint"] = "Name (e.g. savings)";
@@ -1344,6 +1347,9 @@ void I18n::loadBuiltinEnglish()
strings_["market_attribution"] = "Price data from CoinGecko"; strings_["market_attribution"] = "Price data from CoinGecko";
strings_["market_btc_price"] = "BTC PRICE"; strings_["market_btc_price"] = "BTC PRICE";
strings_["market_no_history"] = "No price history available"; strings_["market_no_history"] = "No price history available";
strings_["market_chart_loading"] = "Loading price history";
strings_["market_style_line"] = "Switch to line chart";
strings_["market_style_candle"] = "Switch to candlesticks";
strings_["market_no_price"] = "No price data"; strings_["market_no_price"] = "No price data";
strings_["market_now"] = "Now"; strings_["market_now"] = "Now";
strings_["market_pct_shielded"] = "%.0f%% Shielded"; strings_["market_pct_shielded"] = "%.0f%% Shielded";

View File

@@ -48,6 +48,7 @@
#include "data/wallet_state.h" #include "data/wallet_state.h"
#include "data/portfolio.h" #include "data/portfolio.h"
#include "data/market_series.h" #include "data/market_series.h"
#include "data/exchange_candles.h"
#include "fake_lite_backend.h" #include "fake_lite_backend.h"
#include <chrono> #include <chrono>
@@ -2903,6 +2904,107 @@ void testMarketSeries()
EXPECT_NEAR(live.back().second, 3.0, 1e-9); 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<size_t>(2));
EXPECT_NEAR(o[0].second, 0.01395, 1e-9); // close = idx 4
EXPECT_EQ(static_cast<long>(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<size_t>(2));
EXPECT_NEAR(n[0].second, 0.031354, 1e-9);
EXPECT_EQ(static_cast<long>(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
// --- 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
// WalletState::addresses view, which the full-node refresh path builds from the // WalletState::addresses view, which the full-node refresh path builds from the
// authoritative z/t lists via rebuildAddressList(). Before the fix that rebuild // authoritative z/t lists via rebuildAddressList(). Before the fix that rebuild
@@ -6281,6 +6383,7 @@ int main()
testConsoleModel(); testConsoleModel();
testPortfolioHelpers(); testPortfolioHelpers();
testMarketSeries(); testMarketSeries();
testExchangeCandles();
testWalletStateAddressListRebuild(); testWalletStateAddressListRebuild();
testConsoleFoldSpans(); testConsoleFoldSpans();
testConsoleScrollController(); testConsoleScrollController();