Compare commits
12 Commits
09e0b962c3
...
bfe8b4d77d
| Author | SHA1 | Date | |
|---|---|---|---|
| bfe8b4d77d | |||
| 78c00daf92 | |||
| e82514f46a | |||
| 76708e9191 | |||
| a767702ff6 | |||
| 40e8128a30 | |||
| 326192e75e | |||
| a107fda9b8 | |||
| d1ff58c374 | |||
| 156735eb06 | |||
| 2f86bec98e | |||
| 6310f51f65 |
Binary file not shown.
@@ -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:",
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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 :",
|
||||||
|
|||||||
@@ -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": "並べ替え:",
|
||||||
|
|||||||
@@ -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": "정렬:",
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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": "Сортировка:",
|
||||||
|
|||||||
@@ -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": "排序:",
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
12
src/app.h
12
src/app.h
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ®[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()
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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.3–1.0, 1.0 = opaque)
|
float ui_opacity_ = 0.50f; // Card/sidebar opacity (0.3–1.0, 1.0 = opaque)
|
||||||
float window_opacity_ = 0.75f; // Background alpha (0.3–1.0, <1 = desktop visible)
|
float window_opacity_ = 0.90f; // Background alpha (0.3–1.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
44
src/data/candle.h
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
// A single OHLC candle — the shared shape for per-exchange candlestick charts. Kept in its own tiny,
|
||||||
|
// dependency-free header so both the parser (data/exchange_candles.h, which pulls in nlohmann/json)
|
||||||
|
// and the model (data/wallet_state.h, included widely) can use it without dragging json everywhere.
|
||||||
|
#include <ctime>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace data {
|
||||||
|
|
||||||
|
struct Candle {
|
||||||
|
std::time_t time = 0; // epoch SECONDS (the candle's open/bucket time)
|
||||||
|
double open = 0.0;
|
||||||
|
double high = 0.0;
|
||||||
|
double low = 0.0;
|
||||||
|
double close = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggregate fine candles into fixed time buckets (e.g. 5-min -> hourly for the 1D view): open = first
|
||||||
|
// open, high = max high, low = min low, close = last close. Input must be ascending by time.
|
||||||
|
inline std::vector<Candle> bucketOHLC(const std::vector<Candle>& src, long windowSec) {
|
||||||
|
std::vector<Candle> out;
|
||||||
|
if (src.empty() || windowSec <= 0) return out;
|
||||||
|
long curBucket = -1;
|
||||||
|
Candle cur;
|
||||||
|
for (const auto& c : src) {
|
||||||
|
const long b = (long)(c.time / windowSec);
|
||||||
|
if (b != curBucket) {
|
||||||
|
if (curBucket >= 0) out.push_back(cur);
|
||||||
|
curBucket = b;
|
||||||
|
cur = c;
|
||||||
|
cur.time = (std::time_t)(b * windowSec);
|
||||||
|
} else {
|
||||||
|
if (c.high > cur.high) cur.high = c.high;
|
||||||
|
if (c.low < cur.low) cur.low = c.low;
|
||||||
|
cur.close = c.close;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (curBucket >= 0) out.push_back(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace data
|
||||||
|
} // namespace dragonx
|
||||||
117
src/data/exchange_candles.h
Normal file
117
src/data/exchange_candles.h
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
for (size_t i = 0; i < n; i++) {
|
auto yOf = [&](double v){ return plotBottom - (float)((v - yMin) / (yMax - yMin)) * plotH; };
|
||||||
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
|
if (hasCandles) {
|
||||||
float x = plotLeft + t * plotW;
|
// Candlesticks: a thin wick (low..high) + a body (open..close), green up / red down.
|
||||||
float y = plotBottom - (float)((s_mkt.history[i] - yMin) / (yMax - yMin)) * plotH;
|
const int cn = (int)candles.size();
|
||||||
points[i] = ImVec2(x, y);
|
const float slotW = plotW / (float)cn;
|
||||||
|
const float bodyW = std::max(1.0f, slotW * 0.62f);
|
||||||
|
for (int i = 0; i < cn; i++) {
|
||||||
|
const auto& c = candles[i];
|
||||||
|
const float xc = plotLeft + ((float)i + 0.5f) * slotW;
|
||||||
|
const ImU32 col = (c.close >= c.open) ? Success() : Error();
|
||||||
|
dl->AddLine(ImVec2(xc, yOf(c.high)), ImVec2(xc, yOf(c.low)), WithAlpha(col, 210),
|
||||||
|
std::max(1.0f, mktDp));
|
||||||
|
float bT = yOf(std::max(c.open, c.close)); // higher price -> smaller y -> body top
|
||||||
|
float bB = yOf(std::min(c.open, c.close));
|
||||||
|
if (bB - bT < 1.5f * mktDp) bB = bT + 1.5f * mktDp; // doji -> keep a visible sliver
|
||||||
|
dl->AddRectFilled(ImVec2(xc - bodyW * 0.5f, bT), ImVec2(xc + bodyW * 0.5f, bB),
|
||||||
|
WithAlpha(col, 230), 1.0f);
|
||||||
|
}
|
||||||
|
// 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++) {
|
||||||
|
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
|
||||||
|
points[i] = ImVec2(plotLeft + t * plotW, yOf(s_mkt.history[i]));
|
||||||
|
}
|
||||||
|
// Flat translucent area fill under the curve (matches the portfolio group sparklines).
|
||||||
|
for (size_t i = 0; i < n; i++) dl->PathLineTo(points[i]);
|
||||||
|
dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
|
||||||
|
dl->PathLineTo(ImVec2(points[0].x, plotBottom));
|
||||||
|
dl->PathFillConcave(WithAlpha(dirCol, 28));
|
||||||
|
// Line (no per-point dots — a clean curve).
|
||||||
|
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flat translucent area fill under the curve (matches the portfolio group sparklines).
|
// High/low price labels at the displayed range's extremes (no dot markers). Line only — the
|
||||||
for (size_t i = 0; i < n; i++) dl->PathLineTo(points[i]);
|
// candlestick wicks already show each period's high/low.
|
||||||
dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
|
if (!hasCandles && n >= 3) {
|
||||||
dl->PathLineTo(ImVec2(points[0].x, plotBottom));
|
|
||||||
dl->PathFillConcave(WithAlpha(dirCol, 28));
|
|
||||||
|
|
||||||
// Line (no per-point dots — a clean curve).
|
|
||||||
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
|
|
||||||
|
|
||||||
// High/low price labels at the displayed range's extremes (no dot markers).
|
|
||||||
if (n >= 3) {
|
|
||||||
size_t hiIdx = (size_t)(std::max_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
|
size_t hiIdx = (size_t)(std::max_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
|
||||||
size_t loIdx = (size_t)(std::min_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
|
size_t loIdx = (size_t)(std::min_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
|
||||||
auto markExtreme = [&](size_t idx, bool high) {
|
auto markExtreme = [&](size_t idx, bool high) {
|
||||||
@@ -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,11 +1709,28 @@ 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 char* msg = TR("market_no_history");
|
const float dp = Layout::dpiScale();
|
||||||
ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
|
const float ecx = chartMin.x + availWidth * 0.5f;
|
||||||
dl->AddText(sub1, sub1->LegacySize,
|
const float ecy = (plotTop + plotBottom) * 0.5f;
|
||||||
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, (plotTop + plotBottom) * 0.5f - ts.y * 0.5f),
|
if (app->isMarketChartLoading()) {
|
||||||
OnSurfaceDisabled(), msg);
|
// 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");
|
||||||
|
ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
|
||||||
|
dl->AddText(sub1, sub1->LegacySize,
|
||||||
|
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, ecy - ts.y * 0.5f),
|
||||||
|
OnSurfaceDisabled(), msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH));
|
ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH));
|
||||||
@@ -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, ¤tExchange,
|
app, ¤tExchange,
|
||||||
chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH,
|
chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH,
|
||||||
&chartTimes, nowSec, chartUp, periodChangePct, periodSuffix
|
&chartTimes, &ohlcCandles, nowSec, chartUp, periodChangePct, periodSuffix
|
||||||
};
|
};
|
||||||
mktDrawPriceHero(mktc);
|
mktDrawPriceHero(mktc);
|
||||||
|
|
||||||
|
|||||||
@@ -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 -----------------------
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user