Spaces:
Running
Running
Thang6822 commited on
Commit Β·
43b0e93
1
Parent(s): b9be111
Deploy OHLC4 forecast line and Hugging Face runtime fixes
Browse files- .gitattributes +1 -0
- Dockerfile +2 -0
- README.md +4 -2
- app.py +42 -2
- backend/launcher.py +79 -51
- backend/main.py +1200 -255
- backend/test_api_regressions.py +386 -0
- frontend/favicon.svg +3 -26
- frontend/index.html +1589 -60
- frontend/workspace.css +603 -0
- frontend/workspace.js +765 -0
- run.bat +3 -17
.gitattributes
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.db filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 1 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.db filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
frontend/favicon.svg filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -5,6 +5,8 @@ WORKDIR /app
|
|
| 5 |
COPY requirements.txt .
|
| 6 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
|
|
|
|
|
|
|
| 8 |
COPY . .
|
| 9 |
|
| 10 |
EXPOSE 7860
|
|
|
|
| 5 |
COPY requirements.txt .
|
| 6 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
|
| 8 |
+
ENV PORT=7860
|
| 9 |
+
|
| 10 |
COPY . .
|
| 11 |
|
| 12 |
EXPOSE 7860
|
README.md
CHANGED
|
@@ -4,6 +4,7 @@ emoji: π
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
# Kronos AI Analysis
|
|
@@ -52,19 +53,20 @@ The startup script now:
|
|
| 52 |
2. repairs a broken virtual environment if needed
|
| 53 |
3. installs the full runtime dependency set
|
| 54 |
4. verifies Kronos can be imported
|
| 55 |
-
5. starts the FastAPI server on
|
| 56 |
|
| 57 |
## Manual Start
|
| 58 |
|
| 59 |
```bat
|
| 60 |
py -3.11 -m venv venv
|
| 61 |
venv\Scripts\python.exe -m pip install -r requirements.txt
|
| 62 |
-
venv\Scripts\python.exe -m
|
| 63 |
```
|
| 64 |
|
| 65 |
## Important Notes
|
| 66 |
|
| 67 |
- This project uses Kronos, not TimesFM.
|
|
|
|
| 68 |
- Kronos source code is loaded from `Kronos-master`.
|
| 69 |
- Kronos weights are loaded from Hugging Face via:
|
| 70 |
- `NeoQuasar/Kronos-base`
|
|
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
# Kronos AI Analysis
|
|
|
|
| 53 |
2. repairs a broken virtual environment if needed
|
| 54 |
3. installs the full runtime dependency set
|
| 55 |
4. verifies Kronos can be imported
|
| 56 |
+
5. starts the FastAPI server on a random free local port and opens the browser automatically
|
| 57 |
|
| 58 |
## Manual Start
|
| 59 |
|
| 60 |
```bat
|
| 61 |
py -3.11 -m venv venv
|
| 62 |
venv\Scripts\python.exe -m pip install -r requirements.txt
|
| 63 |
+
venv\Scripts\python.exe -m backend.launcher
|
| 64 |
```
|
| 65 |
|
| 66 |
## Important Notes
|
| 67 |
|
| 68 |
- This project uses Kronos, not TimesFM.
|
| 69 |
+
- Hugging Face Docker Spaces are pinned to port `7860`, while the local Windows launcher uses a random free port.
|
| 70 |
- Kronos source code is loaded from `Kronos-master`.
|
| 71 |
- Kronos weights are loaded from Hugging Face via:
|
| 72 |
- `NeoQuasar/Kronos-base`
|
app.py
CHANGED
|
@@ -1,7 +1,47 @@
|
|
|
|
|
|
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
import uvicorn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from backend.main import app
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
if __name__ == "__main__":
|
| 6 |
-
|
| 7 |
-
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
import os
|
| 4 |
+
import socket
|
| 5 |
+
|
| 6 |
import uvicorn
|
| 7 |
+
|
| 8 |
+
def find_free_port(host: str = "127.0.0.1") -> int:
|
| 9 |
+
"""Ask the OS for a currently available local TCP port."""
|
| 10 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
|
| 11 |
+
server_socket.bind((host, 0))
|
| 12 |
+
server_socket.listen(1)
|
| 13 |
+
return int(server_socket.getsockname()[1])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def is_huggingface_space() -> bool:
|
| 17 |
+
"""Return True when running inside a Hugging Face Space runtime."""
|
| 18 |
+
return bool(os.getenv("SPACE_ID") or os.getenv("SPACE_HOST"))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def bootstrap_runtime_port() -> None:
|
| 22 |
+
"""Seed PORT early so backend imports cannot override the Hugging Face runtime port."""
|
| 23 |
+
if is_huggingface_space() and not os.getenv("PORT", "").strip():
|
| 24 |
+
os.environ["PORT"] = "7860"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
bootstrap_runtime_port()
|
| 28 |
+
|
| 29 |
from backend.main import app
|
| 30 |
|
| 31 |
+
|
| 32 |
+
def resolve_server_port() -> int:
|
| 33 |
+
"""Use the runtime PORT when defined, keep Hugging Face on 7860, else pick a free local port."""
|
| 34 |
+
raw_port = os.getenv("PORT", "").strip()
|
| 35 |
+
if raw_port:
|
| 36 |
+
return int(raw_port)
|
| 37 |
+
if is_huggingface_space():
|
| 38 |
+
os.environ["PORT"] = "7860"
|
| 39 |
+
return 7860
|
| 40 |
+
|
| 41 |
+
port = find_free_port()
|
| 42 |
+
os.environ["PORT"] = str(port)
|
| 43 |
+
return port
|
| 44 |
+
|
| 45 |
+
|
| 46 |
if __name__ == "__main__":
|
| 47 |
+
uvicorn.run(app, host="0.0.0.0", port=resolve_server_port())
|
|
|
backend/launcher.py
CHANGED
|
@@ -1,71 +1,99 @@
|
|
| 1 |
-
import
|
|
|
|
|
|
|
| 2 |
import os
|
|
|
|
|
|
|
|
|
|
| 3 |
import time
|
| 4 |
import webbrowser
|
| 5 |
-
import
|
|
|
|
|
|
|
| 6 |
import uvicorn
|
| 7 |
-
import logging
|
| 8 |
|
| 9 |
-
# Set up professional logging
|
| 10 |
logging.basicConfig(
|
| 11 |
level=logging.INFO,
|
| 12 |
format="%(asctime)s | %(levelname)s | %(message)s",
|
| 13 |
)
|
| 14 |
-
logger = logging.getLogger("
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
# ASCII Art Header
|
| 17 |
BANNER = r"""
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
| 23 |
-
|
| 24 |
-
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
-
TRADING INTELLIGENT TERMINAL - AI PHΓN TΓCH BIα»U Δα» NαΊΎN Sα» 1 THαΊΎ GIα»I
|
| 26 |
-
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
"""
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
| 32 |
print("\033[96m" + BANNER + "\033[0m")
|
| 33 |
-
print(" [*]
|
| 34 |
-
print(" [*]
|
| 35 |
-
print(" [*]
|
| 36 |
-
print(
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
from main import app
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
webbrowser.open(url)
|
| 53 |
|
|
|
|
| 54 |
if __name__ == "__main__":
|
| 55 |
print_banner()
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
try:
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
logger.error(f"Lα»i khα»i Δα»ng hα» thα»ng: {e}")
|
| 69 |
-
print("\n [!] CΓ³ lα»i xαΊ£y ra trong quΓ‘ trΓ¬nh khα»i Δα»ng.")
|
| 70 |
-
print(" [!] NhαΊ₯n Enter Δα» xem chi tiαΊΏt lα»i vΓ thoΓ‘t...")
|
| 71 |
input()
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
import os
|
| 5 |
+
import socket
|
| 6 |
+
import sys
|
| 7 |
+
import threading
|
| 8 |
import time
|
| 9 |
import webbrowser
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
import uvicorn
|
|
|
|
| 14 |
|
|
|
|
| 15 |
logging.basicConfig(
|
| 16 |
level=logging.INFO,
|
| 17 |
format="%(asctime)s | %(levelname)s | %(message)s",
|
| 18 |
)
|
| 19 |
+
logger = logging.getLogger("kronos-launcher")
|
| 20 |
+
|
| 21 |
+
DEFAULT_HOST: str = "127.0.0.1"
|
| 22 |
+
SERVER_BOOT_DELAY_SECONDS: float = 2.5
|
| 23 |
+
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
|
| 24 |
|
|
|
|
| 25 |
BANNER = r"""
|
| 26 |
+
_ ___ _ _ ___
|
| 27 |
+
| |/ / | __ ___ _ __ ___ ___ / \ / \ |_ _|
|
| 28 |
+
| ' /| |/ _` \ \/ / '_ \ / _ \/ __| / _ \ / _ \ | |
|
| 29 |
+
| . \| | (_| |> <| | | | (_) \__ \/ ___ \ / ___ \ | |
|
| 30 |
+
|_|\_\_|\__,_/_/\_\_| |_|\___/|___/_/ \_\/_/ \_\___|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
"""
|
| 32 |
|
| 33 |
+
|
| 34 |
+
def print_banner() -> None:
|
| 35 |
+
"""Render a clean startup banner in the local console."""
|
| 36 |
+
os.system("cls" if os.name == "nt" else "clear")
|
| 37 |
print("\033[96m" + BANNER + "\033[0m")
|
| 38 |
+
print(" [*] Dang khoi dong he thong phan tich AI...")
|
| 39 |
+
print(" [*] Dang nap du lieu thi truong...")
|
| 40 |
+
print(" [*] Trinh duyet se tu dong mo khi server san sang.")
|
| 41 |
+
print()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def load_app() -> Any:
|
| 45 |
+
"""Import the FastAPI app after runtime settings are in place."""
|
| 46 |
+
project_root = str(PROJECT_ROOT)
|
| 47 |
+
if project_root not in sys.path:
|
| 48 |
+
sys.path.insert(0, project_root)
|
| 49 |
+
from backend.main import app as fastapi_app
|
| 50 |
+
return fastapi_app
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def find_free_port(host: str = DEFAULT_HOST) -> int:
|
| 54 |
+
"""Ask the OS for a currently available local TCP port."""
|
| 55 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
|
| 56 |
+
server_socket.bind((host, 0))
|
| 57 |
+
server_socket.listen(1)
|
| 58 |
+
return int(server_socket.getsockname()[1])
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def resolve_server_port() -> int:
|
| 62 |
+
"""Use PORT when defined, otherwise choose a random free local port."""
|
| 63 |
+
raw_port = os.getenv("PORT", "").strip()
|
| 64 |
+
if raw_port:
|
| 65 |
+
try:
|
| 66 |
+
return int(raw_port)
|
| 67 |
+
except ValueError as exc:
|
| 68 |
+
raise ValueError(f"Invalid PORT value: {raw_port}") from exc
|
| 69 |
+
|
| 70 |
+
port = find_free_port()
|
| 71 |
+
os.environ["PORT"] = str(port)
|
| 72 |
+
return port
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def open_browser(url: str) -> None:
|
| 76 |
+
"""Wait briefly for the server to start, then open the dashboard."""
|
| 77 |
+
time.sleep(SERVER_BOOT_DELAY_SECONDS)
|
| 78 |
+
logger.info("Dang mo bang dieu khien tai %s", url)
|
| 79 |
webbrowser.open(url)
|
| 80 |
|
| 81 |
+
|
| 82 |
if __name__ == "__main__":
|
| 83 |
print_banner()
|
| 84 |
+
|
| 85 |
+
host = DEFAULT_HOST
|
| 86 |
+
port = resolve_server_port()
|
| 87 |
+
url = f"http://{host}:{port}"
|
| 88 |
+
app = load_app()
|
| 89 |
+
|
| 90 |
+
logger.info("Khoi dong Backend Core tren %s", url)
|
| 91 |
+
threading.Thread(target=open_browser, args=(url,), daemon=True).start()
|
| 92 |
+
|
| 93 |
try:
|
| 94 |
+
uvicorn.run(app, host=host, port=port, log_level="warning")
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
logger.exception("Loi khoi dong he thong: %s", exc)
|
| 97 |
+
print("\n [!] Co loi xay ra trong qua trinh khoi dong.")
|
| 98 |
+
print(" [!] Nhan Enter de thoat...")
|
|
|
|
|
|
|
|
|
|
| 99 |
input()
|
backend/main.py
CHANGED
|
@@ -141,7 +141,6 @@ class Settings(BaseModel):
|
|
| 141 |
binance_api_secret: Optional[str] = os.getenv("BINANCE_API_SECRET")
|
| 142 |
bybit_api_key: Optional[str] = os.getenv("BYBIT_API_KEY")
|
| 143 |
bybit_api_secret: Optional[str] = os.getenv("BYBIT_API_SECRET")
|
| 144 |
-
gemini_api_key: Optional[str] = os.getenv("GEMINI_API_KEY")
|
| 145 |
alphavantage_api_key: Optional[str] = os.getenv("ALPHAVANTAGE_API_KEY")
|
| 146 |
admin_token: str = os.getenv("ADMIN_TOKEN", DEFAULT_ADMIN_TOKEN)
|
| 147 |
|
|
@@ -379,6 +378,14 @@ class CircuitBreaker:
|
|
| 379 |
self.state = "OPEN"
|
| 380 |
logger.error("[CB] %s is now OPEN - circuit broken", self.name)
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
# Instance per source
|
| 383 |
source_breakers: Dict[str, CircuitBreaker] = {
|
| 384 |
s: CircuitBreaker(s, settings.cb_failure_threshold, settings.cb_recovery_timeout)
|
|
@@ -465,10 +472,77 @@ persistent_cache = PersistentCache(
|
|
| 465 |
# B-12: Global Configuration Instances
|
| 466 |
CACHE_VERSION = settings.cache_version
|
| 467 |
ADMIN_TOKEN = settings.admin_token
|
| 468 |
-
TWELVEDATA_API_KEY = settings.twelvedata_api_key
|
| 469 |
-
FINNHUB_API_KEY = settings.finnhub_api_key
|
| 470 |
CORS_ALLOW_ORIGINS = parse_cors_origins(settings.cors_allow_origins_raw)
|
| 471 |
CORS_ALLOW_CREDENTIALS = CORS_ALLOW_ORIGINS != ["*"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
# Binance, Bybit, CoinGecko, yfinance, FRED β no key or optional key required
|
| 473 |
|
| 474 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -509,7 +583,8 @@ STEP_SECONDS: Dict[str, int] = {
|
|
| 509 |
# Source priority by asset category (first available mapping wins)
|
| 510 |
CATEGORY_SOURCE_PRIORITY: Dict[str, List[str]] = {
|
| 511 |
"Crypto": ["binance", "bybit", "coingecko", "yfinance", "finnhub"],
|
| 512 |
-
"CαΊ·p tiα»n": ["
|
|
|
|
| 513 |
"Kim loαΊ‘i": ["binance", "twelvedata", "yfinance", "finnhub"],
|
| 514 |
"NΔng lượng": ["twelvedata", "yfinance", "finnhub"],
|
| 515 |
"NΓ΄ng sαΊ£n": ["twelvedata", "yfinance"],
|
|
@@ -556,11 +631,14 @@ class TokenBucket:
|
|
| 556 |
|
| 557 |
|
| 558 |
# Per-source buckets (conservative β stays well within free limits)
|
|
|
|
|
|
|
|
|
|
| 559 |
_rate_limiters: Dict[str, TokenBucket] = {
|
| 560 |
"binance": TokenBucket(rate=10.0, capacity=20),
|
| 561 |
"bybit": TokenBucket(rate=1.5, capacity=5),
|
| 562 |
"coingecko": TokenBucket(rate=0.4, capacity=3),
|
| 563 |
-
"twelvedata": TokenBucket(rate=
|
| 564 |
"finnhub": TokenBucket(rate=1.0, capacity=5),
|
| 565 |
"yfinance": TokenBucket(rate=5.0, capacity=10),
|
| 566 |
"alphavantage":TokenBucket(rate=0.02, capacity=1), # 25/day
|
|
@@ -601,6 +679,25 @@ class SymbolConfig:
|
|
| 601 |
description: str = ""
|
| 602 |
|
| 603 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
# βββ Helper to build entry quickly ββββββββββββββββββββββββββββββββββββββββββββ
|
| 605 |
def _s(sym: str, label: str, label_en: str, cat: str,
|
| 606 |
mappings: Dict[str, str], cg_id: str = None, desc: str = "",
|
|
@@ -697,17 +794,43 @@ SYMBOLS: Dict[str, SymbolConfig] = {
|
|
| 697 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 698 |
# 6. CαΊΆP TIα»N (Forex)
|
| 699 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 700 |
-
"DXY": _s("DXY","Chα» sα» USD (DXY)","USD Index","
|
| 701 |
-
"
|
| 702 |
-
"
|
| 703 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
"USDCHF": _s("USDCHF","USD/CHF","USD/CHF","CαΊ·p tiα»n",{"twelvedata":"USD/CHF","yfinance":"CHF=X"}),
|
| 705 |
-
"AUDUSD": _s("AUDUSD","AUD/USD","AUD/USD","CαΊ·p tiα»n",{"
|
| 706 |
"USDCAD": _s("USDCAD","USD/CAD","USD/CAD","CαΊ·p tiα»n",{"twelvedata":"USD/CAD","yfinance":"CAD=X"}),
|
| 707 |
"NZDUSD": _s("NZDUSD","NZD/USD","NZD/USD","CαΊ·p tiα»n",{"twelvedata":"NZD/USD","yfinance":"NZDUSD=X"}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
"GBPJPY": _s("GBPJPY","GBP/JPY","GBP/JPY","CαΊ·p tiα»n",{"twelvedata":"GBP/JPY","yfinance":"GBPJPY=X"}),
|
| 709 |
-
"
|
| 710 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
"USDVND": _s("USDVND","USD/VND","USD/VND","CαΊ·p tiα»n",{"yfinance":"VND=X"}),
|
| 712 |
"USDCNH": _s("USDCNH","USD/CNH","USD/CNH","CαΊ·p tiα»n",{"twelvedata":"USD/CNH"}),
|
| 713 |
"USDHKD": _s("USDHKD","USD/HKD","USD/HKD","CαΊ·p tiα»n",{"twelvedata":"USD/HKD"}),
|
|
@@ -793,6 +916,129 @@ SYMBOLS: Dict[str, SymbolConfig] = {
|
|
| 793 |
# TTL Cache (unchanged from v3, with improved stats)
|
| 794 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 795 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
def _get_canonical_symbol(sym: str) -> str:
|
| 797 |
"""Try to find the registry ID for a given symbol or alias."""
|
| 798 |
s = sym.upper()
|
|
@@ -817,6 +1063,7 @@ forecast_cache = TTLCache()
|
|
| 817 |
ticker_cache = TTLCache()
|
| 818 |
ai_verdict_cache = TTLCache()
|
| 819 |
indicators_cache = TTLCache()
|
|
|
|
| 820 |
|
| 821 |
_watchlist_ticker_semaphore = asyncio.Semaphore(WATCHLIST_TICKER_CONCURRENCY)
|
| 822 |
_market_peer_ticker_semaphore = asyncio.Semaphore(MARKET_PEER_TICKER_CONCURRENCY)
|
|
@@ -827,6 +1074,7 @@ forecast_cache.clear()
|
|
| 827 |
ticker_cache.clear()
|
| 828 |
ai_verdict_cache.clear()
|
| 829 |
indicators_cache.clear()
|
|
|
|
| 830 |
|
| 831 |
|
| 832 |
def _cache_prefix(symbol: str, interval: str) -> str:
|
|
@@ -837,7 +1085,7 @@ def interval_ttl(interval: str) -> int:
|
|
| 837 |
if interval in {"1m", "5m"}: return 20
|
| 838 |
if interval == "15m": return 30
|
| 839 |
if interval in {"1h", "4h"}: return 60
|
| 840 |
-
return
|
| 841 |
|
| 842 |
|
| 843 |
def forecast_ttl(interval: str) -> int:
|
|
@@ -895,6 +1143,7 @@ _HISTORICAL_INFLIGHT: Dict[str, "asyncio.Task[Tuple[List[Dict[str, Any]], str]]"
|
|
| 895 |
_INDICATORS_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
|
| 896 |
_FORECAST_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
|
| 897 |
_TICKER_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
|
|
|
|
| 898 |
|
| 899 |
|
| 900 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -1165,7 +1414,7 @@ async def fetch_twelvedata(symbol: str, interval: str, limit: int) -> List[Dict[
|
|
| 1165 |
"symbol": endpoint_symbol,
|
| 1166 |
"interval": TWELVE_INTERVAL_MAP[interval],
|
| 1167 |
"outputsize": min(max(limit, 30), 5000),
|
| 1168 |
-
"apikey":
|
| 1169 |
"format": "JSON",
|
| 1170 |
}
|
| 1171 |
logger.info("[TwelveData] %s %s", symbol, interval)
|
|
@@ -1180,7 +1429,9 @@ async def fetch_twelvedata(symbol: str, interval: str, limit: int) -> List[Dict[
|
|
| 1180 |
resp = await client.get("https://api.twelvedata.com/time_series", params=params)
|
| 1181 |
if resp.status_code == 429:
|
| 1182 |
cb.record_failure()
|
| 1183 |
-
|
|
|
|
|
|
|
| 1184 |
if resp.status_code >= 500:
|
| 1185 |
cb.record_failure()
|
| 1186 |
raise HTTPException(status_code=resp.status_code, detail="TwelveData server error")
|
|
@@ -1192,7 +1443,10 @@ async def fetch_twelvedata(symbol: str, interval: str, limit: int) -> List[Dict[
|
|
| 1192 |
raise ex
|
| 1193 |
|
| 1194 |
payload = await _retry(_fetch)
|
| 1195 |
-
if
|
|
|
|
|
|
|
|
|
|
| 1196 |
raise RuntimeError(f"TwelveData error: {payload}")
|
| 1197 |
values = payload.get("values", [])
|
| 1198 |
parsed = [
|
|
@@ -1223,7 +1477,7 @@ async def fetch_finnhub(symbol: str, interval: str, limit: int) -> List[Dict[str
|
|
| 1223 |
"symbol": mappings["finnhub"],
|
| 1224 |
"resolution": FINNHUB_RESOLUTION_MAP.get(interval, "D"),
|
| 1225 |
"count": limit,
|
| 1226 |
-
"token":
|
| 1227 |
},
|
| 1228 |
timeout=10,
|
| 1229 |
)
|
|
@@ -1328,6 +1582,364 @@ async def fetch_alphavantage(symbol: str, interval: str, limit: int) -> List[Dic
|
|
| 1328 |
return _normalize_ohlcv(parsed, interval)[-limit:]
|
| 1329 |
|
| 1330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
|
| 1332 |
|
| 1333 |
def _get_source_priority(symbol: str) -> List[str]:
|
|
@@ -1342,6 +1954,9 @@ async def _run_historical_fetch(
|
|
| 1342 |
fetch_limit: int,
|
| 1343 |
cache_key: str,
|
| 1344 |
) -> Tuple[List[Dict[str, Any]], str]:
|
|
|
|
|
|
|
|
|
|
| 1345 |
priority = _get_source_priority(symbol)
|
| 1346 |
errors: List[str] = []
|
| 1347 |
|
|
@@ -1368,8 +1983,17 @@ async def _run_historical_fetch(
|
|
| 1368 |
historical_cache.set(cache_key, (data, source), ttl_seconds=interval_ttl(interval))
|
| 1369 |
return data, source
|
| 1370 |
errors.append(f"{source}: insufficient ({len(data)} candles)")
|
| 1371 |
-
except HTTPException:
|
| 1372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1373 |
except Exception as ex:
|
| 1374 |
errors.append(f"{source}: {ex}")
|
| 1375 |
logger.warning("[fetch_historical] %s/%s %s: %s", symbol, interval, source, ex)
|
|
@@ -1429,17 +2053,43 @@ async def fetch_historical(
|
|
| 1429 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1430 |
# Real-time Ticker (last price + 24h stats)
|
| 1431 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1432 |
-
async def fetch_ticker(symbol: str) -> Dict[str, Any]:
|
| 1433 |
-
|
|
|
|
|
|
|
| 1434 |
if cached:
|
| 1435 |
return cached
|
| 1436 |
|
| 1437 |
-
inflight_key =
|
| 1438 |
inflight_task = _TICKER_INFLIGHT.get(inflight_key)
|
| 1439 |
if inflight_task is not None:
|
| 1440 |
return await inflight_task
|
| 1441 |
|
| 1442 |
async def _run() -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1443 |
cfg = SYMBOLS[symbol]
|
| 1444 |
priority = _get_source_priority(symbol)
|
| 1445 |
client = await GlobalHTTPClient.get_client()
|
|
@@ -1469,7 +2119,7 @@ async def fetch_ticker(symbol: str) -> Dict[str, Any]:
|
|
| 1469 |
await _rate_limit("twelvedata")
|
| 1470 |
r = await client.get(
|
| 1471 |
"https://api.twelvedata.com/quote",
|
| 1472 |
-
params={"symbol": cfg.mappings["twelvedata"], "apikey":
|
| 1473 |
timeout=10.0,
|
| 1474 |
)
|
| 1475 |
d = r.json()
|
|
@@ -1549,8 +2199,8 @@ async def fetch_ticker(symbol: str) -> Dict[str, Any]:
|
|
| 1549 |
continue
|
| 1550 |
|
| 1551 |
res.update({"symbol": symbol, "timestamp": int(time.time())})
|
| 1552 |
-
ttl = 5 if cfg.category == "Crypto" else (10 if cfg.category in ("
|
| 1553 |
-
ticker_cache.set(
|
| 1554 |
return res
|
| 1555 |
except Exception as ex:
|
| 1556 |
logger.debug("[ticker] %s/%s failed: %s", symbol, source, ex)
|
|
@@ -1558,7 +2208,7 @@ async def fetch_ticker(symbol: str) -> Dict[str, Any]:
|
|
| 1558 |
|
| 1559 |
raise HTTPException(status_code=502, detail=f"Ticker failed for {symbol} after trying {priority}")
|
| 1560 |
|
| 1561 |
-
task = asyncio.create_task(_run(), name=f"ticker:{symbol}")
|
| 1562 |
_TICKER_INFLIGHT[inflight_key] = task
|
| 1563 |
try:
|
| 1564 |
return await task
|
|
@@ -3754,12 +4404,18 @@ class KronosForecaster:
|
|
| 3754 |
self._predictor: Optional[Any] = None
|
| 3755 |
self._loaded = False
|
| 3756 |
self._lock: Optional[asyncio.Lock] = None
|
|
|
|
| 3757 |
|
| 3758 |
async def _get_lock(self) -> asyncio.Lock:
|
| 3759 |
if self._lock is None:
|
| 3760 |
self._lock = asyncio.Lock()
|
| 3761 |
return self._lock
|
| 3762 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3763 |
@property
|
| 3764 |
def is_ready(self) -> bool:
|
| 3765 |
return self._loaded
|
|
@@ -3774,6 +4430,45 @@ class KronosForecaster:
|
|
| 3774 |
return CLIP_DEFAULT
|
| 3775 |
return getattr(self._predictor, "clip", CLIP_DEFAULT)
|
| 3776 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3777 |
async def _lazy_load(self) -> None:
|
| 3778 |
if self._loaded:
|
| 3779 |
return
|
|
@@ -3789,6 +4484,7 @@ class KronosForecaster:
|
|
| 3789 |
else "cpu")
|
| 3790 |
logger.info("[Kronos] Loading on %s β¦", device)
|
| 3791 |
tokenizer = await asyncio.to_thread(KronosTokenizer.from_pretrained, "NeoQuasar/Kronos-Tokenizer-base")
|
|
|
|
| 3792 |
model = await asyncio.to_thread(Kronos.from_pretrained, self.MODEL_NAME)
|
| 3793 |
self._predictor = KronosPredictor(model, tokenizer, device=device, max_context=self.MAX_CONTEXT)
|
| 3794 |
self._loaded = True
|
|
@@ -3798,12 +4494,27 @@ class KronosForecaster:
|
|
| 3798 |
raise HTTPException(status_code=500, detail=f"Kronos init failed: {ex}")
|
| 3799 |
|
| 3800 |
@staticmethod
|
| 3801 |
-
def
|
| 3802 |
-
|
| 3803 |
-
|
| 3804 |
-
|
| 3805 |
-
|
| 3806 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3807 |
|
| 3808 |
async def forecast(self, df: pd.DataFrame, x_timestamp: pd.Series,
|
| 3809 |
y_timestamp: pd.Series, horizon: int,
|
|
@@ -3816,60 +4527,72 @@ class KronosForecaster:
|
|
| 3816 |
if not isinstance(y_timestamp, pd.Series):
|
| 3817 |
y_timestamp = pd.Series(y_timestamp.values if hasattr(y_timestamp, "values") else y_timestamp)
|
| 3818 |
|
| 3819 |
-
|
| 3820 |
-
|
| 3821 |
-
amount = df["amount"].values.astype(np.float32)
|
| 3822 |
-
else:
|
| 3823 |
-
typical = ((df["high"] + df["low"] + df["close"]) / 3).values.astype(np.float32)
|
| 3824 |
-
amount = vol * typical
|
| 3825 |
-
|
| 3826 |
-
x = np.stack([df["open"].values, df["high"].values, df["low"].values,
|
| 3827 |
-
df["close"].values, vol, amount], axis=1).astype(np.float32)
|
| 3828 |
|
| 3829 |
x_stamp = calc_time_stamps(x_timestamp).values.astype(np.float32)
|
| 3830 |
y_stamp = calc_time_stamps(y_timestamp).values.astype(np.float32)
|
| 3831 |
|
| 3832 |
-
x_mean =
|
| 3833 |
-
x_std = np.std(x, axis=0)
|
| 3834 |
-
x_std_safe = np.where(x_std < 1e-8, 1.0, x_std)
|
| 3835 |
-
x_norm = np.clip((x - x_mean) / x_std_safe, -self._clip, self._clip)
|
| 3836 |
|
| 3837 |
x_norm = x_norm[np.newaxis, :]
|
| 3838 |
x_stamp = x_stamp[np.newaxis, :]
|
| 3839 |
y_stamp = y_stamp[np.newaxis, :]
|
| 3840 |
|
| 3841 |
t0 = time.time()
|
| 3842 |
-
|
| 3843 |
-
|
| 3844 |
-
|
| 3845 |
-
|
| 3846 |
-
|
| 3847 |
-
|
|
|
|
|
|
|
| 3848 |
logger.info("[Kronos] %.2fs | horizon=%d samples=%d ctx=%d",
|
| 3849 |
time.time() - t0, horizon, sample_count, len(df))
|
| 3850 |
|
| 3851 |
if "cuda" in self.device:
|
| 3852 |
torch.cuda.empty_cache()
|
| 3853 |
|
| 3854 |
-
|
| 3855 |
# Some Kronos checkpoints return the full decoded sequence rather than
|
| 3856 |
# only the requested pred_len. Keep the most recent horizon window so
|
| 3857 |
# downstream logic always receives a forecast-length vector.
|
| 3858 |
-
if
|
| 3859 |
-
|
| 3860 |
-
elif
|
| 3861 |
-
pad_width = horizon -
|
| 3862 |
-
|
| 3863 |
|
| 3864 |
-
|
|
|
|
|
|
|
|
|
|
| 3865 |
|
| 3866 |
return {
|
| 3867 |
-
"p10":
|
| 3868 |
-
"p50":
|
| 3869 |
-
"p90":
|
| 3870 |
"model_name": self.MODEL_NAME,
|
| 3871 |
"context_length": len(df),
|
| 3872 |
-
"output_horizon": int(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3873 |
}
|
| 3874 |
except Exception as ex:
|
| 3875 |
logger.error("[Kronos] Forecast failed: %s", ex, exc_info=True)
|
|
@@ -3879,6 +4602,15 @@ class KronosForecaster:
|
|
| 3879 |
forecaster = KronosForecaster()
|
| 3880 |
|
| 3881 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3882 |
|
| 3883 |
# MODULE: Analysis Engine v2.0 (Relocated and Activated)
|
| 3884 |
# Legacy placeholders removed to avoid duplication with logic at line 1884.
|
|
@@ -3944,6 +4676,7 @@ async def websocket_price(websocket: WebSocket, symbol: str):
|
|
| 3944 |
if symbol not in SYMBOLS:
|
| 3945 |
await websocket.close(code=1008, reason=f"Unknown symbol: {symbol}")
|
| 3946 |
return
|
|
|
|
| 3947 |
|
| 3948 |
try:
|
| 3949 |
await ws_manager.connect(websocket, symbol)
|
|
@@ -3954,7 +4687,7 @@ async def websocket_price(websocket: WebSocket, symbol: str):
|
|
| 3954 |
break
|
| 3955 |
|
| 3956 |
# 2. Fetch fresh price
|
| 3957 |
-
ticker = await
|
| 3958 |
|
| 3959 |
# 3. Final state check before send
|
| 3960 |
if websocket.client_state == WebSocketState.CONNECTED:
|
|
@@ -4368,11 +5101,11 @@ async def get_local_ai_verdict(
|
|
| 4368 |
|
| 4369 |
|
| 4370 |
@app.get("/api/ticker/{symbol}")
|
| 4371 |
-
async def get_ticker(symbol: str) -> Dict[str, Any]:
|
| 4372 |
symbol = _get_canonical_symbol(symbol)
|
| 4373 |
if symbol not in SYMBOLS:
|
| 4374 |
raise HTTPException(404, f"Unknown symbol: {symbol}")
|
| 4375 |
-
return await fetch_ticker(symbol)
|
| 4376 |
|
| 4377 |
|
| 4378 |
# ββ Watchlist (batch ticker) ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -4402,6 +5135,358 @@ async def get_watchlist_tickers(body: WatchlistRequest) -> Dict[str, Any]:
|
|
| 4402 |
}
|
| 4403 |
|
| 4404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4405 |
# ββ Forecast ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4406 |
@app.get("/api/forecast/{symbol}")
|
| 4407 |
async def get_forecast(
|
|
@@ -4416,23 +5501,13 @@ async def get_forecast(
|
|
| 4416 |
if interval not in SUPPORTED_INTERVALS:
|
| 4417 |
raise HTTPException(400, f"Unsupported interval: {interval}")
|
| 4418 |
|
| 4419 |
-
|
| 4420 |
-
cache_key = f"forecast_{prefix}{horizon}"
|
| 4421 |
cache_origin = "live"
|
| 4422 |
|
| 4423 |
if not refresh:
|
| 4424 |
-
cached =
|
| 4425 |
if cached is not None:
|
| 4426 |
-
cached["generated_at"] = int(time.time())
|
| 4427 |
-
cached["cache"] = {"origin": "memory", "refresh_requested": False}
|
| 4428 |
return cached
|
| 4429 |
-
p_cached = persistent_cache.get(cache_key)
|
| 4430 |
-
if p_cached is not None:
|
| 4431 |
-
p_cached["from_persistent_cache"] = True
|
| 4432 |
-
forecast_cache.set(cache_key, p_cached, ttl_seconds=forecast_ttl(interval))
|
| 4433 |
-
p_cached["generated_at"] = int(time.time())
|
| 4434 |
-
p_cached["cache"] = {"origin": "persistent", "refresh_requested": False}
|
| 4435 |
-
return p_cached
|
| 4436 |
else:
|
| 4437 |
logger.info("[forecast] Refresh requested for %s %s. Bypassing caches.", symbol, interval)
|
| 4438 |
cache_origin = "live_refresh"
|
|
@@ -4443,130 +5518,14 @@ async def get_forecast(
|
|
| 4443 |
return await inflight_task
|
| 4444 |
|
| 4445 |
async def _build_forecast_response() -> Dict[str, Any]:
|
| 4446 |
-
|
| 4447 |
-
symbol,
|
| 4448 |
-
interval,
|
| 4449 |
-
FORECAST_CONTEXT,
|
| 4450 |
-
refresh=refresh,
|
| 4451 |
-
min_context=FORECAST_CONTEXT,
|
| 4452 |
-
)
|
| 4453 |
-
if not KRONOS_AVAILABLE:
|
| 4454 |
-
return {
|
| 4455 |
-
"symbol": symbol,
|
| 4456 |
-
"interval": interval,
|
| 4457 |
-
"forecast": [],
|
| 4458 |
-
"error": "AI Forecaster is currently offline or not found in bundle.",
|
| 4459 |
-
"path_checked": KRONOS_PATH,
|
| 4460 |
-
"ai_runtime": {"mode": "local_only", "model": "offline"},
|
| 4461 |
-
}
|
| 4462 |
-
|
| 4463 |
-
if len(data_list) < 40:
|
| 4464 |
-
raise HTTPException(422, "Insufficient historical data for forecasting")
|
| 4465 |
-
|
| 4466 |
-
df_hist = pd.DataFrame(data_list)
|
| 4467 |
-
df_hist["timestamps"] = pd.to_datetime(df_hist["time"], unit="s", utc=True)
|
| 4468 |
-
|
| 4469 |
-
if "amount" not in df_hist.columns or df_hist["amount"].isna().all() or df_hist["amount"].sum() == 0:
|
| 4470 |
-
typical = (df_hist["high"] + df_hist["low"] + df_hist["close"]) / 3
|
| 4471 |
-
df_hist["amount"] = (df_hist["volume"] * typical).fillna(0)
|
| 4472 |
-
else:
|
| 4473 |
-
df_hist["amount"] = df_hist["amount"].fillna(0)
|
| 4474 |
-
|
| 4475 |
-
context_len = min(len(df_hist), KronosForecaster.MAX_CONTEXT)
|
| 4476 |
-
df_context = df_hist.tail(context_len).reset_index(drop=True)
|
| 4477 |
-
|
| 4478 |
-
logger.info("[forecast] %s %s | ctx=%d/%d | horizon=%d", symbol, interval, context_len, len(df_hist), horizon)
|
| 4479 |
-
|
| 4480 |
-
last_time = int(df_hist["time"].iloc[-1])
|
| 4481 |
-
step = STEP_SECONDS[interval]
|
| 4482 |
-
y_timestamps = pd.Series(pd.to_datetime(
|
| 4483 |
-
[last_time + step * (i + 1) for i in range(horizon)], unit="s", utc=True
|
| 4484 |
-
))
|
| 4485 |
-
|
| 4486 |
-
sample_count = 10 if forecaster.device in {"not_loaded", "cpu"} else 15
|
| 4487 |
-
model_output = await forecaster.forecast(
|
| 4488 |
-
df=df_context[["open", "high", "low", "close", "volume", "amount"]],
|
| 4489 |
-
x_timestamp=df_context["timestamps"],
|
| 4490 |
-
y_timestamp=y_timestamps,
|
| 4491 |
-
horizon=horizon,
|
| 4492 |
-
sample_count=sample_count,
|
| 4493 |
-
)
|
| 4494 |
-
|
| 4495 |
-
last_close = float(df_hist["close"].iloc[-1])
|
| 4496 |
-
anchor_output = _build_anchor_forecast(data_list, indicators, horizon, interval)
|
| 4497 |
-
blended = _blend_forecasts(last_close, model_output, anchor_output, indicators)
|
| 4498 |
-
|
| 4499 |
-
logger.info(
|
| 4500 |
-
"[forecast] ensemble | %s %s | model_weight=%.2f anchor_weight=%.2f confidence=%.1f agreement=%s",
|
| 4501 |
-
symbol,
|
| 4502 |
-
interval,
|
| 4503 |
-
blended["model_weight"],
|
| 4504 |
-
blended["anchor_weight"],
|
| 4505 |
-
blended["confidence"],
|
| 4506 |
-
blended["agreement"],
|
| 4507 |
-
)
|
| 4508 |
-
|
| 4509 |
-
forecast_rows: List[Dict[str, Any]] = [
|
| 4510 |
-
{"time": last_time, "p10": last_close, "p50": last_close, "p90": last_close, "is_actual": True}
|
| 4511 |
-
]
|
| 4512 |
-
for i in range(horizon):
|
| 4513 |
-
forecast_rows.append({
|
| 4514 |
-
"time": int(last_time + step * (i + 1)),
|
| 4515 |
-
"p10": round(float(blended["p10"][i]), 6),
|
| 4516 |
-
"p50": round(float(blended["p50"][i]), 6),
|
| 4517 |
-
"p90": round(float(blended["p90"][i]), 6),
|
| 4518 |
-
})
|
| 4519 |
-
|
| 4520 |
-
analysis = await asyncio.to_thread(
|
| 4521 |
-
_build_trade_analysis,
|
| 4522 |
symbol=symbol,
|
| 4523 |
interval=interval,
|
| 4524 |
-
|
| 4525 |
-
|
| 4526 |
-
|
| 4527 |
-
confidence=float(blended["confidence"]),
|
| 4528 |
-
source=source,
|
| 4529 |
-
blended=blended,
|
| 4530 |
)
|
| 4531 |
-
|
| 4532 |
-
response = {
|
| 4533 |
-
"symbol": symbol,
|
| 4534 |
-
"interval": interval,
|
| 4535 |
-
"source": source,
|
| 4536 |
-
"horizon": horizon,
|
| 4537 |
-
"last_close": last_close,
|
| 4538 |
-
"forecast": forecast_rows,
|
| 4539 |
-
"from_persistent_cache": False,
|
| 4540 |
-
"model": {
|
| 4541 |
-
"name": model_output.get("model_name", "Kronos-base"),
|
| 4542 |
-
"context_length": int(model_output.get("context_length", context_len)),
|
| 4543 |
-
"quantiles": [0.1, 0.5, 0.9],
|
| 4544 |
-
"cache_version": CACHE_VERSION,
|
| 4545 |
-
"sample_count": sample_count,
|
| 4546 |
-
},
|
| 4547 |
-
"ensemble": {
|
| 4548 |
-
"mode": "kronos_plus_anchor",
|
| 4549 |
-
"model_weight": blended["model_weight"],
|
| 4550 |
-
"anchor_weight": blended["anchor_weight"],
|
| 4551 |
-
"trend_agreement": blended["agreement"],
|
| 4552 |
-
"confidence": blended["confidence"],
|
| 4553 |
-
"model_bias_pct": blended["model_bias_pct"],
|
| 4554 |
-
"alignment_scale": blended["scale"],
|
| 4555 |
-
},
|
| 4556 |
-
"indicators_snapshot": indicators,
|
| 4557 |
-
"analysis": analysis,
|
| 4558 |
-
"generated_at": int(time.time()),
|
| 4559 |
-
"cache": {
|
| 4560 |
-
"origin": cache_origin,
|
| 4561 |
-
"refresh_requested": refresh,
|
| 4562 |
-
},
|
| 4563 |
-
"ai_runtime": {
|
| 4564 |
-
"mode": "local_only",
|
| 4565 |
-
"model": str(model_output.get("model_name", "Kronos-base")),
|
| 4566 |
-
"device": forecaster.device,
|
| 4567 |
-
},
|
| 4568 |
-
}
|
| 4569 |
-
response = make_json_compatible(response)
|
| 4570 |
forecast_cache.set(cache_key, response, ttl_seconds=forecast_ttl(interval))
|
| 4571 |
persistent_cache.set(cache_key, response, ttl=forecast_ttl(interval) * 4)
|
| 4572 |
return response
|
|
@@ -4699,48 +5658,6 @@ async def get_ai_rules() -> Dict[str, Any]:
|
|
| 4699 |
return ai_rule_registry.snapshot()
|
| 4700 |
|
| 4701 |
|
| 4702 |
-
async def fetch_gemini_analysis(prompt: str, system_instruction: Optional[str] = None) -> str:
|
| 4703 |
-
"""Fetch AI analysis from Google Gemini."""
|
| 4704 |
-
if not settings.gemini_api_key:
|
| 4705 |
-
return "Gemini API key is not configured."
|
| 4706 |
-
|
| 4707 |
-
url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent"
|
| 4708 |
-
headers = {"Content-Type": "application/json"}
|
| 4709 |
-
payload = {
|
| 4710 |
-
"contents": [{"parts": [{"text": prompt}]}]
|
| 4711 |
-
}
|
| 4712 |
-
if system_instruction:
|
| 4713 |
-
payload["system_instruction"] = {
|
| 4714 |
-
"parts": [{"text": system_instruction}]
|
| 4715 |
-
}
|
| 4716 |
-
|
| 4717 |
-
try:
|
| 4718 |
-
client = await GlobalHTTPClient.get_client()
|
| 4719 |
-
resp = await client.post(
|
| 4720 |
-
url,
|
| 4721 |
-
headers=headers,
|
| 4722 |
-
params={"key": settings.gemini_api_key},
|
| 4723 |
-
json=payload,
|
| 4724 |
-
timeout=30.0,
|
| 4725 |
-
)
|
| 4726 |
-
if resp.status_code != 200:
|
| 4727 |
-
if logger.error("[Gemini] Error: %d - %s", resp.status_code, resp.text) is None:
|
| 4728 |
-
return "PhΓ’n tΓch AI khΓ΄ng khαΊ£ dα»₯ng"
|
| 4729 |
-
|
| 4730 |
-
if True:
|
| 4731 |
-
data = resp.json()
|
| 4732 |
-
candidates = data.get("candidates", [])
|
| 4733 |
-
if candidates and candidates[0].get("content", {}).get("parts"):
|
| 4734 |
-
text = candidates[0]["content"]["parts"][0].get("text", "").strip()
|
| 4735 |
-
# Ensure we only return the verdict text if it's a verdict call
|
| 4736 |
-
# We'll rely on the prompt to enforce this, but can sanitize here
|
| 4737 |
-
return text
|
| 4738 |
-
return "KhΓ΄ng cΓ³ phαΊ£n hα»i tα»« AI"
|
| 4739 |
-
except Exception as e:
|
| 4740 |
-
logger.error("[Gemini] Exception: %s", e, exc_info=True)
|
| 4741 |
-
return "Lα»i phΓ’n tΓch AI"
|
| 4742 |
-
|
| 4743 |
-
|
| 4744 |
@app.get("/api/metrics")
|
| 4745 |
async def get_metrics(request: Request):
|
| 4746 |
"""Export Prometheus-ready metrics (latencies, cache hits, CB states)."""
|
|
@@ -4842,6 +5759,23 @@ if os.path.exists(FRONTEND_PATH):
|
|
| 4842 |
INDEX_PATH = os.path.join(FRONTEND_PATH, "index.html")
|
| 4843 |
AIBG_PATH = os.path.join(FRONTEND_PATH, "AIBG.png")
|
| 4844 |
FAVICON_PATH = os.path.join(FRONTEND_PATH, "favicon.svg")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4845 |
|
| 4846 |
@app.get("/", include_in_schema=False)
|
| 4847 |
@app.get("/index.html", include_in_schema=False)
|
|
@@ -4850,13 +5784,32 @@ if os.path.exists(FRONTEND_PATH):
|
|
| 4850 |
raise HTTPException(status_code=404, detail="Frontend index not found")
|
| 4851 |
|
| 4852 |
html = Path(INDEX_PATH).read_text(encoding="utf-8")
|
| 4853 |
-
|
| 4854 |
-
|
| 4855 |
-
"Pragma": "no-cache",
|
| 4856 |
-
"Expires": "0",
|
| 4857 |
-
}
|
| 4858 |
return HTMLResponse(content=html, headers=headers)
|
| 4859 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4860 |
@app.get("/AIBG.png", include_in_schema=False)
|
| 4861 |
async def serve_aibg() -> FileResponse:
|
| 4862 |
if not os.path.exists(AIBG_PATH):
|
|
@@ -4865,11 +5818,7 @@ if os.path.exists(FRONTEND_PATH):
|
|
| 4865 |
return FileResponse(
|
| 4866 |
AIBG_PATH,
|
| 4867 |
media_type="image/png",
|
| 4868 |
-
headers=
|
| 4869 |
-
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
| 4870 |
-
"Pragma": "no-cache",
|
| 4871 |
-
"Expires": "0",
|
| 4872 |
-
},
|
| 4873 |
)
|
| 4874 |
|
| 4875 |
@app.get("/favicon.svg", include_in_schema=False)
|
|
@@ -4881,11 +5830,7 @@ if os.path.exists(FRONTEND_PATH):
|
|
| 4881 |
return FileResponse(
|
| 4882 |
FAVICON_PATH,
|
| 4883 |
media_type="image/svg+xml",
|
| 4884 |
-
headers=
|
| 4885 |
-
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
| 4886 |
-
"Pragma": "no-cache",
|
| 4887 |
-
"Expires": "0",
|
| 4888 |
-
},
|
| 4889 |
)
|
| 4890 |
|
| 4891 |
app.mount("/", StaticFiles(directory=FRONTEND_PATH, html=True), name="frontend")
|
|
|
|
| 141 |
binance_api_secret: Optional[str] = os.getenv("BINANCE_API_SECRET")
|
| 142 |
bybit_api_key: Optional[str] = os.getenv("BYBIT_API_KEY")
|
| 143 |
bybit_api_secret: Optional[str] = os.getenv("BYBIT_API_SECRET")
|
|
|
|
| 144 |
alphavantage_api_key: Optional[str] = os.getenv("ALPHAVANTAGE_API_KEY")
|
| 145 |
admin_token: str = os.getenv("ADMIN_TOKEN", DEFAULT_ADMIN_TOKEN)
|
| 146 |
|
|
|
|
| 378 |
self.state = "OPEN"
|
| 379 |
logger.error("[CB] %s is now OPEN - circuit broken", self.name)
|
| 380 |
|
| 381 |
+
def open_for(self, timeout: Optional[int] = None) -> None:
|
| 382 |
+
if timeout is not None:
|
| 383 |
+
self.timeout = max(self.timeout, timeout)
|
| 384 |
+
self.failures = max(self.failures, self.threshold)
|
| 385 |
+
self.last_failure_time = time.time()
|
| 386 |
+
self.state = "OPEN"
|
| 387 |
+
logger.error("[CB] %s is OPEN for %ss", self.name, self.timeout)
|
| 388 |
+
|
| 389 |
# Instance per source
|
| 390 |
source_breakers: Dict[str, CircuitBreaker] = {
|
| 391 |
s: CircuitBreaker(s, settings.cb_failure_threshold, settings.cb_recovery_timeout)
|
|
|
|
| 472 |
# B-12: Global Configuration Instances
|
| 473 |
CACHE_VERSION = settings.cache_version
|
| 474 |
ADMIN_TOKEN = settings.admin_token
|
|
|
|
|
|
|
| 475 |
CORS_ALLOW_ORIGINS = parse_cors_origins(settings.cors_allow_origins_raw)
|
| 476 |
CORS_ALLOW_CREDENTIALS = CORS_ALLOW_ORIGINS != ["*"]
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
# ββ API Key Pool (round-robin rotation for multi-pane) ββββββββββββββββββββββββ
|
| 480 |
+
class APIKeyPool:
|
| 481 |
+
"""Round-robin key rotation to distribute API calls across multiple keys."""
|
| 482 |
+
|
| 483 |
+
def __init__(self, primary_key, pool_csv, name=""):
|
| 484 |
+
self._name = name
|
| 485 |
+
self._keys = []
|
| 486 |
+
self._index = 0
|
| 487 |
+
self._exhausted = set()
|
| 488 |
+
|
| 489 |
+
if pool_csv:
|
| 490 |
+
self._keys = [k.strip() for k in pool_csv.split(",") if k.strip()]
|
| 491 |
+
if not self._keys and primary_key:
|
| 492 |
+
self._keys = [primary_key]
|
| 493 |
+
|
| 494 |
+
logger.info("[APIKeyPool] %s: %d keys loaded", name, len(self._keys))
|
| 495 |
+
|
| 496 |
+
@property
|
| 497 |
+
def primary(self):
|
| 498 |
+
return self._keys[0] if self._keys else None
|
| 499 |
+
|
| 500 |
+
def next_key(self):
|
| 501 |
+
if not self._keys:
|
| 502 |
+
return None
|
| 503 |
+
available = [k for k in self._keys if k not in self._exhausted]
|
| 504 |
+
if not available:
|
| 505 |
+
self._exhausted.clear()
|
| 506 |
+
available = self._keys
|
| 507 |
+
key = available[self._index % len(available)]
|
| 508 |
+
self._index += 1
|
| 509 |
+
return key
|
| 510 |
+
|
| 511 |
+
def mark_exhausted(self, key):
|
| 512 |
+
self._exhausted.add(key)
|
| 513 |
+
remaining = len(self._keys) - len(self._exhausted)
|
| 514 |
+
logger.warning("[APIKeyPool] %s: key ...%s exhausted (%d remaining)",
|
| 515 |
+
self._name, key[-8:], remaining)
|
| 516 |
+
|
| 517 |
+
def reset(self):
|
| 518 |
+
self._exhausted.clear()
|
| 519 |
+
self._index = 0
|
| 520 |
+
|
| 521 |
+
@property
|
| 522 |
+
def pool_size(self):
|
| 523 |
+
return len(self._keys)
|
| 524 |
+
|
| 525 |
+
@property
|
| 526 |
+
def available_count(self):
|
| 527 |
+
return len(self._keys) - len(self._exhausted)
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
twelve_keys = [os.getenv(f"TWELVEDATA_API_KEY_{i}") for i in range(1, 9)]
|
| 531 |
+
twelvedata_pool = APIKeyPool(
|
| 532 |
+
settings.twelvedata_api_key,
|
| 533 |
+
",".join([k for k in twelve_keys if k]),
|
| 534 |
+
name="TwelveData",
|
| 535 |
+
)
|
| 536 |
+
finnhub_keys = [os.getenv(f"FINNHUB_API_KEY_{i}") for i in range(1, 9)]
|
| 537 |
+
finnhub_pool = APIKeyPool(
|
| 538 |
+
settings.finnhub_api_key,
|
| 539 |
+
",".join([k for k in finnhub_keys if k]),
|
| 540 |
+
name="Finnhub",
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
# Backward compat: keep single-key globals pointing to primary
|
| 544 |
+
TWELVEDATA_API_KEY = twelvedata_pool.primary
|
| 545 |
+
FINNHUB_API_KEY = finnhub_pool.primary
|
| 546 |
# Binance, Bybit, CoinGecko, yfinance, FRED β no key or optional key required
|
| 547 |
|
| 548 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 583 |
# Source priority by asset category (first available mapping wins)
|
| 584 |
CATEGORY_SOURCE_PRIORITY: Dict[str, List[str]] = {
|
| 585 |
"Crypto": ["binance", "bybit", "coingecko", "yfinance", "finnhub"],
|
| 586 |
+
"CαΊ·p tiα»n": ["twelvedata", "finnhub", "yfinance"],
|
| 587 |
+
"Real Strength": ["yfinance", "twelvedata", "finnhub"],
|
| 588 |
"Kim loαΊ‘i": ["binance", "twelvedata", "yfinance", "finnhub"],
|
| 589 |
"NΔng lượng": ["twelvedata", "yfinance", "finnhub"],
|
| 590 |
"NΓ΄ng sαΊ£n": ["twelvedata", "yfinance"],
|
|
|
|
| 631 |
|
| 632 |
|
| 633 |
# Per-source buckets (conservative β stays well within free limits)
|
| 634 |
+
_TWELVEDATA_POOL_SIZE = max(1, twelvedata_pool.pool_size)
|
| 635 |
+
_TWELVEDATA_RATE = min(1.0, 0.1 * _TWELVEDATA_POOL_SIZE)
|
| 636 |
+
_TWELVEDATA_CAPACITY = max(2, min(8, _TWELVEDATA_POOL_SIZE))
|
| 637 |
_rate_limiters: Dict[str, TokenBucket] = {
|
| 638 |
"binance": TokenBucket(rate=10.0, capacity=20),
|
| 639 |
"bybit": TokenBucket(rate=1.5, capacity=5),
|
| 640 |
"coingecko": TokenBucket(rate=0.4, capacity=3),
|
| 641 |
+
"twelvedata": TokenBucket(rate=_TWELVEDATA_RATE, capacity=_TWELVEDATA_CAPACITY),
|
| 642 |
"finnhub": TokenBucket(rate=1.0, capacity=5),
|
| 643 |
"yfinance": TokenBucket(rate=5.0, capacity=10),
|
| 644 |
"alphavantage":TokenBucket(rate=0.02, capacity=1), # 25/day
|
|
|
|
| 679 |
description: str = ""
|
| 680 |
|
| 681 |
|
| 682 |
+
@dataclass(frozen=True)
|
| 683 |
+
class SyntheticComponentSpec:
|
| 684 |
+
name: str
|
| 685 |
+
mode: str
|
| 686 |
+
left_symbol: str
|
| 687 |
+
right_symbol: Optional[str] = None
|
| 688 |
+
weight: float = 1.0
|
| 689 |
+
enabled: bool = True
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
@dataclass(frozen=True)
|
| 693 |
+
class SyntheticSymbolConfig:
|
| 694 |
+
symbol: str
|
| 695 |
+
scale: float
|
| 696 |
+
alpha: float
|
| 697 |
+
wick_shrink: float
|
| 698 |
+
components: Tuple[SyntheticComponentSpec, ...]
|
| 699 |
+
|
| 700 |
+
|
| 701 |
# βββ Helper to build entry quickly ββββββββββββββββββββββββββββββββββββββββββββ
|
| 702 |
def _s(sym: str, label: str, label_en: str, cat: str,
|
| 703 |
mappings: Dict[str, str], cg_id: str = None, desc: str = "",
|
|
|
|
| 794 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 795 |
# 6. CαΊΆP TIα»N (Forex)
|
| 796 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 797 |
+
"DXY": _s("DXY","Chα» sα» USD (DXY)","USD Index","Real Strength",{"twelvedata":"DXY","yfinance":"DX-Y.NYB"}),
|
| 798 |
+
"USDX": _s("USDX","Sα»©c mαΊ‘nh USD (USDx)","USD Strength (USDx)","Real Strength",{"synthetic":"USDX"}, desc="Synthetic USD strength index built from weighted USD crosses."),
|
| 799 |
+
"EURX": _s("EURX","Sα»©c mαΊ‘nh EUR (EURx)","EUR Strength (EURx)","Real Strength",{"synthetic":"EURX"}, desc="Synthetic EUR strength index built from weighted EUR crosses."),
|
| 800 |
+
"GBPX": _s("GBPX","Sα»©c mαΊ‘nh GBP (GBPx)","GBP Strength (GBPx)","Real Strength",{"synthetic":"GBPX"}, desc="Synthetic GBP strength index built from weighted GBP crosses."),
|
| 801 |
+
"CHFX": _s("CHFX","Sα»©c mαΊ‘nh CHF (CHFx)","CHF Strength (CHFx)","Real Strength",{"synthetic":"CHFX"}, desc="Synthetic CHF strength index built from weighted CHF crosses."),
|
| 802 |
+
"JPYX": _s("JPYX","Sα»©c mαΊ‘nh JPY (JPYx)","JPY Strength (JPYx)","Real Strength",{"synthetic":"JPYX"}, desc="Synthetic JPY strength index built from weighted JPY crosses."),
|
| 803 |
+
"CADX": _s("CADX","Sα»©c mαΊ‘nh CAD (CADx)","CAD Strength (CADx)","Real Strength",{"synthetic":"CADX"}, desc="Synthetic CAD strength index built from weighted CAD crosses."),
|
| 804 |
+
"AUDX": _s("AUDX","Sα»©c mαΊ‘nh AUD (AUDx)","AUD Strength (AUDx)","Real Strength",{"synthetic":"AUDX"}, desc="Synthetic AUD strength index built from weighted AUD crosses."),
|
| 805 |
+
"NZDX": _s("NZDX","Sα»©c mαΊ‘nh NZD (NZDx)","NZD Strength (NZDx)","Real Strength",{"synthetic":"NZDX"}, desc="Synthetic NZD strength index built from weighted NZD crosses."),
|
| 806 |
+
"EURUSD": _s("EURUSD","EUR/USD","EUR/USD","CαΊ·p tiα»n",{"twelvedata":"EUR/USD","yfinance":"EURUSD=X"}),
|
| 807 |
+
"GBPUSD": _s("GBPUSD","GBP/USD","GBP/USD","CαΊ·p tiα»n",{"twelvedata":"GBP/USD","yfinance":"GBPUSD=X"}),
|
| 808 |
+
"USDJPY": _s("USDJPY","USD/JPY","USD/JPY","CαΊ·p tiα»n",{"twelvedata":"USD/JPY","yfinance":"JPY=X"}),
|
| 809 |
"USDCHF": _s("USDCHF","USD/CHF","USD/CHF","CαΊ·p tiα»n",{"twelvedata":"USD/CHF","yfinance":"CHF=X"}),
|
| 810 |
+
"AUDUSD": _s("AUDUSD","AUD/USD","AUD/USD","CαΊ·p tiα»n",{"twelvedata":"AUD/USD","yfinance":"AUDUSD=X"}),
|
| 811 |
"USDCAD": _s("USDCAD","USD/CAD","USD/CAD","CαΊ·p tiα»n",{"twelvedata":"USD/CAD","yfinance":"CAD=X"}),
|
| 812 |
"NZDUSD": _s("NZDUSD","NZD/USD","NZD/USD","CαΊ·p tiα»n",{"twelvedata":"NZD/USD","yfinance":"NZDUSD=X"}),
|
| 813 |
+
"EURGBP": _s("EURGBP","EUR/GBP","EUR/GBP","CαΊ·p tiα»n",{"twelvedata":"EUR/GBP","yfinance":"EURGBP=X"}),
|
| 814 |
+
"EURJPY": _s("EURJPY","EUR/JPY","EUR/JPY","CαΊ·p tiα»n",{"twelvedata":"EUR/JPY","yfinance":"EURJPY=X"}),
|
| 815 |
+
"EURCHF": _s("EURCHF","EUR/CHF","EUR/CHF","CαΊ·p tiα»n",{"twelvedata":"EUR/CHF","yfinance":"EURCHF=X"}),
|
| 816 |
+
"EURCAD": _s("EURCAD","EUR/CAD","EUR/CAD","CαΊ·p tiα»n",{"twelvedata":"EUR/CAD","yfinance":"EURCAD=X"}),
|
| 817 |
+
"EURAUD": _s("EURAUD","EUR/AUD","EUR/AUD","CαΊ·p tiα»n",{"twelvedata":"EUR/AUD","yfinance":"EURAUD=X"}),
|
| 818 |
+
"EURNZD": _s("EURNZD","EUR/NZD","EUR/NZD","CαΊ·p tiα»n",{"twelvedata":"EUR/NZD","yfinance":"EURNZD=X"}),
|
| 819 |
"GBPJPY": _s("GBPJPY","GBP/JPY","GBP/JPY","CαΊ·p tiα»n",{"twelvedata":"GBP/JPY","yfinance":"GBPJPY=X"}),
|
| 820 |
+
"GBPCHF": _s("GBPCHF","GBP/CHF","GBP/CHF","CαΊ·p tiα»n",{"twelvedata":"GBP/CHF","yfinance":"GBPCHF=X"}),
|
| 821 |
+
"GBPCAD": _s("GBPCAD","GBP/CAD","GBP/CAD","CαΊ·p tiα»n",{"twelvedata":"GBP/CAD","yfinance":"GBPCAD=X"}),
|
| 822 |
+
"GBPAUD": _s("GBPAUD","GBP/AUD","GBP/AUD","CαΊ·p tiα»n",{"twelvedata":"GBP/AUD","yfinance":"GBPAUD=X"}),
|
| 823 |
+
"GBPNZD": _s("GBPNZD","GBP/NZD","GBP/NZD","CαΊ·p tiα»n",{"twelvedata":"GBP/NZD","yfinance":"GBPNZD=X"}),
|
| 824 |
+
"CHFJPY": _s("CHFJPY","CHF/JPY","CHF/JPY","CαΊ·p tiα»n",{"twelvedata":"CHF/JPY","yfinance":"CHFJPY=X"}),
|
| 825 |
+
"CADCHF": _s("CADCHF","CAD/CHF","CAD/CHF","CαΊ·p tiα»n",{"twelvedata":"CAD/CHF","yfinance":"CADCHF=X"}),
|
| 826 |
+
"AUDCHF": _s("AUDCHF","AUD/CHF","AUD/CHF","CαΊ·p tiα»n",{"twelvedata":"AUD/CHF","yfinance":"AUDCHF=X"}),
|
| 827 |
+
"NZDCHF": _s("NZDCHF","NZD/CHF","NZD/CHF","CαΊ·p tiα»n",{"twelvedata":"NZD/CHF","yfinance":"NZDCHF=X"}),
|
| 828 |
+
"CADJPY": _s("CADJPY","CAD/JPY","CAD/JPY","CαΊ·p tiα»n",{"twelvedata":"CAD/JPY","yfinance":"CADJPY=X"}),
|
| 829 |
+
"AUDJPY": _s("AUDJPY","AUD/JPY","AUD/JPY","CαΊ·p tiα»n",{"twelvedata":"AUD/JPY","yfinance":"AUDJPY=X"}),
|
| 830 |
+
"NZDJPY": _s("NZDJPY","NZD/JPY","NZD/JPY","CαΊ·p tiα»n",{"twelvedata":"NZD/JPY","yfinance":"NZDJPY=X"}),
|
| 831 |
+
"AUDCAD": _s("AUDCAD","AUD/CAD","AUD/CAD","CαΊ·p tiα»n",{"twelvedata":"AUD/CAD","yfinance":"AUDCAD=X"}),
|
| 832 |
+
"NZDCAD": _s("NZDCAD","NZD/CAD","NZD/CAD","CαΊ·p tiα»n",{"twelvedata":"NZD/CAD","yfinance":"NZDCAD=X"}),
|
| 833 |
+
"AUDNZD": _s("AUDNZD","AUD/NZD","AUD/NZD","CαΊ·p tiα»n",{"twelvedata":"AUD/NZD","yfinance":"AUDNZD=X"}),
|
| 834 |
"USDVND": _s("USDVND","USD/VND","USD/VND","CαΊ·p tiα»n",{"yfinance":"VND=X"}),
|
| 835 |
"USDCNH": _s("USDCNH","USD/CNH","USD/CNH","CαΊ·p tiα»n",{"twelvedata":"USD/CNH"}),
|
| 836 |
"USDHKD": _s("USDHKD","USD/HKD","USD/HKD","CαΊ·p tiα»n",{"twelvedata":"USD/HKD"}),
|
|
|
|
| 916 |
# TTL Cache (unchanged from v3, with improved stats)
|
| 917 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 918 |
|
| 919 |
+
SYNTHETIC_SYMBOLS: Dict[str, SyntheticSymbolConfig] = {
|
| 920 |
+
"USDX": SyntheticSymbolConfig(
|
| 921 |
+
symbol="USDX",
|
| 922 |
+
scale=43.0,
|
| 923 |
+
alpha=1.0,
|
| 924 |
+
wick_shrink=0.8,
|
| 925 |
+
components=(
|
| 926 |
+
SyntheticComponentSpec(name="EURUSD", mode="inverse", left_symbol="EURUSD"),
|
| 927 |
+
SyntheticComponentSpec(name="GBPUSD", mode="inverse", left_symbol="GBPUSD"),
|
| 928 |
+
SyntheticComponentSpec(name="USDCHF", mode="direct", left_symbol="USDCHF"),
|
| 929 |
+
SyntheticComponentSpec(name="USDJPY", mode="direct", left_symbol="USDJPY"),
|
| 930 |
+
SyntheticComponentSpec(name="USDCAD", mode="direct", left_symbol="USDCAD"),
|
| 931 |
+
SyntheticComponentSpec(name="AUDUSD", mode="inverse", left_symbol="AUDUSD"),
|
| 932 |
+
SyntheticComponentSpec(name="NZDUSD", mode="inverse", left_symbol="NZDUSD"),
|
| 933 |
+
),
|
| 934 |
+
),
|
| 935 |
+
"EURX": SyntheticSymbolConfig(
|
| 936 |
+
symbol="EURX",
|
| 937 |
+
scale=43.0,
|
| 938 |
+
alpha=1.0,
|
| 939 |
+
wick_shrink=0.6,
|
| 940 |
+
components=(
|
| 941 |
+
SyntheticComponentSpec(name="EURUSD", mode="direct", left_symbol="EURUSD"),
|
| 942 |
+
SyntheticComponentSpec(name="EURGBP", mode="direct", left_symbol="EURGBP"),
|
| 943 |
+
SyntheticComponentSpec(name="EURCHF", mode="product", left_symbol="EURUSD", right_symbol="USDCHF"),
|
| 944 |
+
SyntheticComponentSpec(name="EURJPY", mode="direct", left_symbol="EURJPY"),
|
| 945 |
+
SyntheticComponentSpec(name="EURCAD", mode="product", left_symbol="EURUSD", right_symbol="USDCAD"),
|
| 946 |
+
SyntheticComponentSpec(name="EURAUD", mode="ratio", left_symbol="EURUSD", right_symbol="AUDUSD"),
|
| 947 |
+
SyntheticComponentSpec(name="EURNZD", mode="ratio", left_symbol="EURUSD", right_symbol="NZDUSD"),
|
| 948 |
+
),
|
| 949 |
+
),
|
| 950 |
+
"GBPX": SyntheticSymbolConfig(
|
| 951 |
+
symbol="GBPX",
|
| 952 |
+
scale=43.0,
|
| 953 |
+
alpha=1.0,
|
| 954 |
+
wick_shrink=0.6,
|
| 955 |
+
components=(
|
| 956 |
+
SyntheticComponentSpec(name="GBPUSD", mode="direct", left_symbol="GBPUSD"),
|
| 957 |
+
SyntheticComponentSpec(name="EURGBP", mode="inverse", left_symbol="EURGBP"),
|
| 958 |
+
SyntheticComponentSpec(name="GBPCHF", mode="product", left_symbol="GBPUSD", right_symbol="USDCHF"),
|
| 959 |
+
SyntheticComponentSpec(name="GBPJPY", mode="direct", left_symbol="GBPJPY"),
|
| 960 |
+
SyntheticComponentSpec(name="GBPCAD", mode="product", left_symbol="GBPUSD", right_symbol="USDCAD"),
|
| 961 |
+
SyntheticComponentSpec(name="GBPAUD", mode="ratio", left_symbol="GBPUSD", right_symbol="AUDUSD"),
|
| 962 |
+
SyntheticComponentSpec(name="GBPNZD", mode="ratio", left_symbol="GBPUSD", right_symbol="NZDUSD"),
|
| 963 |
+
),
|
| 964 |
+
),
|
| 965 |
+
"CHFX": SyntheticSymbolConfig(
|
| 966 |
+
symbol="CHFX",
|
| 967 |
+
scale=43.0,
|
| 968 |
+
alpha=1.0,
|
| 969 |
+
wick_shrink=0.8,
|
| 970 |
+
components=(
|
| 971 |
+
SyntheticComponentSpec(name="USDCHF", mode="inverse", left_symbol="USDCHF"),
|
| 972 |
+
SyntheticComponentSpec(name="EURCHF", mode="inverse", left_symbol="EURCHF"),
|
| 973 |
+
SyntheticComponentSpec(name="GBPCHF", mode="inverse", left_symbol="GBPCHF"),
|
| 974 |
+
SyntheticComponentSpec(name="CHFJPY", mode="direct", left_symbol="CHFJPY"),
|
| 975 |
+
SyntheticComponentSpec(name="CADCHF", mode="inverse", left_symbol="CADCHF"),
|
| 976 |
+
SyntheticComponentSpec(name="AUDCHF", mode="inverse", left_symbol="AUDCHF"),
|
| 977 |
+
SyntheticComponentSpec(name="NZDCHF", mode="inverse", left_symbol="NZDCHF"),
|
| 978 |
+
),
|
| 979 |
+
),
|
| 980 |
+
"JPYX": SyntheticSymbolConfig(
|
| 981 |
+
symbol="JPYX",
|
| 982 |
+
scale=43.0,
|
| 983 |
+
alpha=1.0,
|
| 984 |
+
wick_shrink=1.0,
|
| 985 |
+
components=(
|
| 986 |
+
SyntheticComponentSpec(name="USDJPY", mode="inverse", left_symbol="USDJPY"),
|
| 987 |
+
SyntheticComponentSpec(name="EURJPY", mode="inverse", left_symbol="EURJPY"),
|
| 988 |
+
SyntheticComponentSpec(name="GBPJPY", mode="inverse", left_symbol="GBPJPY"),
|
| 989 |
+
SyntheticComponentSpec(name="CHFJPY", mode="inverse", left_symbol="CHFJPY"),
|
| 990 |
+
SyntheticComponentSpec(name="CADJPY", mode="inverse", left_symbol="CADJPY"),
|
| 991 |
+
SyntheticComponentSpec(name="AUDJPY", mode="inverse", left_symbol="AUDJPY"),
|
| 992 |
+
SyntheticComponentSpec(name="NZDJPY", mode="inverse", left_symbol="NZDJPY"),
|
| 993 |
+
),
|
| 994 |
+
),
|
| 995 |
+
"CADX": SyntheticSymbolConfig(
|
| 996 |
+
symbol="CADX",
|
| 997 |
+
scale=44.0,
|
| 998 |
+
alpha=1.0,
|
| 999 |
+
wick_shrink=0.8,
|
| 1000 |
+
components=(
|
| 1001 |
+
SyntheticComponentSpec(name="USDCAD", mode="inverse", left_symbol="USDCAD"),
|
| 1002 |
+
SyntheticComponentSpec(name="EURCAD", mode="inverse", left_symbol="EURCAD"),
|
| 1003 |
+
SyntheticComponentSpec(name="GBPCAD", mode="inverse", left_symbol="GBPCAD"),
|
| 1004 |
+
SyntheticComponentSpec(name="CADCHF", mode="direct", left_symbol="CADCHF"),
|
| 1005 |
+
SyntheticComponentSpec(name="CADJPY", mode="direct", left_symbol="CADJPY"),
|
| 1006 |
+
SyntheticComponentSpec(name="AUDCAD", mode="inverse", left_symbol="AUDCAD"),
|
| 1007 |
+
SyntheticComponentSpec(name="NZDCAD", mode="inverse", left_symbol="NZDCAD"),
|
| 1008 |
+
),
|
| 1009 |
+
),
|
| 1010 |
+
"AUDX": SyntheticSymbolConfig(
|
| 1011 |
+
symbol="AUDX",
|
| 1012 |
+
scale=44.0,
|
| 1013 |
+
alpha=1.0,
|
| 1014 |
+
wick_shrink=0.6,
|
| 1015 |
+
components=(
|
| 1016 |
+
SyntheticComponentSpec(name="AUDUSD", mode="direct", left_symbol="AUDUSD"),
|
| 1017 |
+
SyntheticComponentSpec(name="EURAUD", mode="inverse", left_symbol="EURAUD"),
|
| 1018 |
+
SyntheticComponentSpec(name="GBPAUD", mode="inverse", left_symbol="GBPAUD"),
|
| 1019 |
+
SyntheticComponentSpec(name="AUDCHF", mode="direct", left_symbol="AUDCHF"),
|
| 1020 |
+
SyntheticComponentSpec(name="AUDJPY", mode="direct", left_symbol="AUDJPY"),
|
| 1021 |
+
SyntheticComponentSpec(name="AUDCAD", mode="direct", left_symbol="AUDCAD"),
|
| 1022 |
+
SyntheticComponentSpec(name="AUDNZD", mode="direct", left_symbol="AUDNZD"),
|
| 1023 |
+
),
|
| 1024 |
+
),
|
| 1025 |
+
"NZDX": SyntheticSymbolConfig(
|
| 1026 |
+
symbol="NZDX",
|
| 1027 |
+
scale=44.0,
|
| 1028 |
+
alpha=1.0,
|
| 1029 |
+
wick_shrink=0.8,
|
| 1030 |
+
components=(
|
| 1031 |
+
SyntheticComponentSpec(name="NZDUSD", mode="direct", left_symbol="NZDUSD"),
|
| 1032 |
+
SyntheticComponentSpec(name="EURNZD", mode="inverse", left_symbol="EURNZD"),
|
| 1033 |
+
SyntheticComponentSpec(name="GBPNZD", mode="inverse", left_symbol="GBPNZD"),
|
| 1034 |
+
SyntheticComponentSpec(name="NZDCHF", mode="direct", left_symbol="NZDCHF"),
|
| 1035 |
+
SyntheticComponentSpec(name="NZDJPY", mode="direct", left_symbol="NZDJPY"),
|
| 1036 |
+
SyntheticComponentSpec(name="NZDCAD", mode="direct", left_symbol="NZDCAD"),
|
| 1037 |
+
SyntheticComponentSpec(name="AUDNZD", mode="inverse", left_symbol="AUDNZD"),
|
| 1038 |
+
),
|
| 1039 |
+
),
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
def _get_canonical_symbol(sym: str) -> str:
|
| 1043 |
"""Try to find the registry ID for a given symbol or alias."""
|
| 1044 |
s = sym.upper()
|
|
|
|
| 1063 |
ticker_cache = TTLCache()
|
| 1064 |
ai_verdict_cache = TTLCache()
|
| 1065 |
indicators_cache = TTLCache()
|
| 1066 |
+
source_history_cache = TTLCache()
|
| 1067 |
|
| 1068 |
_watchlist_ticker_semaphore = asyncio.Semaphore(WATCHLIST_TICKER_CONCURRENCY)
|
| 1069 |
_market_peer_ticker_semaphore = asyncio.Semaphore(MARKET_PEER_TICKER_CONCURRENCY)
|
|
|
|
| 1074 |
ticker_cache.clear()
|
| 1075 |
ai_verdict_cache.clear()
|
| 1076 |
indicators_cache.clear()
|
| 1077 |
+
source_history_cache.clear()
|
| 1078 |
|
| 1079 |
|
| 1080 |
def _cache_prefix(symbol: str, interval: str) -> str:
|
|
|
|
| 1085 |
if interval in {"1m", "5m"}: return 20
|
| 1086 |
if interval == "15m": return 30
|
| 1087 |
if interval in {"1h", "4h"}: return 60
|
| 1088 |
+
return 900
|
| 1089 |
|
| 1090 |
|
| 1091 |
def forecast_ttl(interval: str) -> int:
|
|
|
|
| 1143 |
_INDICATORS_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
|
| 1144 |
_FORECAST_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
|
| 1145 |
_TICKER_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
|
| 1146 |
+
_SOURCE_HISTORY_INFLIGHT: Dict[str, "asyncio.Task[List[Dict[str, Any]]]"] = {}
|
| 1147 |
|
| 1148 |
|
| 1149 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 1414 |
"symbol": endpoint_symbol,
|
| 1415 |
"interval": TWELVE_INTERVAL_MAP[interval],
|
| 1416 |
"outputsize": min(max(limit, 30), 5000),
|
| 1417 |
+
"apikey": twelvedata_pool.next_key(),
|
| 1418 |
"format": "JSON",
|
| 1419 |
}
|
| 1420 |
logger.info("[TwelveData] %s %s", symbol, interval)
|
|
|
|
| 1429 |
resp = await client.get("https://api.twelvedata.com/time_series", params=params)
|
| 1430 |
if resp.status_code == 429:
|
| 1431 |
cb.record_failure()
|
| 1432 |
+
twelvedata_pool.mark_exhausted(params["apikey"])
|
| 1433 |
+
cb.open_for(timeout=1800)
|
| 1434 |
+
raise RuntimeError("TwelveData rate limit")
|
| 1435 |
if resp.status_code >= 500:
|
| 1436 |
cb.record_failure()
|
| 1437 |
raise HTTPException(status_code=resp.status_code, detail="TwelveData server error")
|
|
|
|
| 1443 |
raise ex
|
| 1444 |
|
| 1445 |
payload = await _retry(_fetch)
|
| 1446 |
+
if payload.get("code") == 429:
|
| 1447 |
+
cb.open_for(timeout=1800)
|
| 1448 |
+
raise RuntimeError(f"TwelveData error: {payload}")
|
| 1449 |
+
if "code" in payload and payload.get("code") == 400:
|
| 1450 |
raise RuntimeError(f"TwelveData error: {payload}")
|
| 1451 |
values = payload.get("values", [])
|
| 1452 |
parsed = [
|
|
|
|
| 1477 |
"symbol": mappings["finnhub"],
|
| 1478 |
"resolution": FINNHUB_RESOLUTION_MAP.get(interval, "D"),
|
| 1479 |
"count": limit,
|
| 1480 |
+
"token": finnhub_pool.next_key(),
|
| 1481 |
},
|
| 1482 |
timeout=10,
|
| 1483 |
)
|
|
|
|
| 1582 |
return _normalize_ohlcv(parsed, interval)[-limit:]
|
| 1583 |
|
| 1584 |
|
| 1585 |
+
async def _fetch_historical_from_source(
|
| 1586 |
+
source: str,
|
| 1587 |
+
symbol: str,
|
| 1588 |
+
interval: str,
|
| 1589 |
+
limit: int,
|
| 1590 |
+
) -> List[Dict[str, Any]]:
|
| 1591 |
+
if source == "binance":
|
| 1592 |
+
return await fetch_binance(symbol, interval, limit)
|
| 1593 |
+
if source == "bybit":
|
| 1594 |
+
return await fetch_bybit(symbol, interval, limit)
|
| 1595 |
+
if source == "coingecko":
|
| 1596 |
+
return await fetch_coingecko(symbol, interval, limit)
|
| 1597 |
+
if source == "twelvedata":
|
| 1598 |
+
return await fetch_twelvedata(symbol, interval, limit)
|
| 1599 |
+
if source == "finnhub":
|
| 1600 |
+
return await fetch_finnhub(symbol, interval, limit)
|
| 1601 |
+
if source == "yfinance":
|
| 1602 |
+
return await fetch_yfinance(symbol, interval, limit)
|
| 1603 |
+
if source == "alphavantage":
|
| 1604 |
+
return await fetch_alphavantage(symbol, interval, limit)
|
| 1605 |
+
raise ValueError(f"Unsupported source fetcher: {source}")
|
| 1606 |
+
|
| 1607 |
+
|
| 1608 |
+
async def _fetch_historical_from_source_cached(
|
| 1609 |
+
source: str,
|
| 1610 |
+
symbol: str,
|
| 1611 |
+
interval: str,
|
| 1612 |
+
limit: int,
|
| 1613 |
+
) -> List[Dict[str, Any]]:
|
| 1614 |
+
"""
|
| 1615 |
+
Deduplicate repeated component fetches used by synthetic symbols.
|
| 1616 |
+
|
| 1617 |
+
This keeps Real Strength baskets from re-downloading the same FX cross
|
| 1618 |
+
multiple times across EURX/GBPX/CHFX/... within one refresh window.
|
| 1619 |
+
"""
|
| 1620 |
+
cache_key = f"source_hist:{CACHE_VERSION}:{source}:{symbol}:{interval}:{limit}"
|
| 1621 |
+
cached = source_history_cache.get(cache_key)
|
| 1622 |
+
if cached is not None:
|
| 1623 |
+
return cached
|
| 1624 |
+
|
| 1625 |
+
inflight_task = _SOURCE_HISTORY_INFLIGHT.get(cache_key)
|
| 1626 |
+
if inflight_task is not None:
|
| 1627 |
+
return await inflight_task
|
| 1628 |
+
|
| 1629 |
+
task = asyncio.create_task(
|
| 1630 |
+
_fetch_historical_from_source(source, symbol, interval, limit),
|
| 1631 |
+
name=f"source_hist:{source}:{symbol}:{interval}:{limit}",
|
| 1632 |
+
)
|
| 1633 |
+
_SOURCE_HISTORY_INFLIGHT[cache_key] = task
|
| 1634 |
+
try:
|
| 1635 |
+
data = await task
|
| 1636 |
+
source_history_cache.set(cache_key, data, ttl_seconds=interval_ttl(interval))
|
| 1637 |
+
return data
|
| 1638 |
+
finally:
|
| 1639 |
+
if _SOURCE_HISTORY_INFLIGHT.get(cache_key) is task:
|
| 1640 |
+
_SOURCE_HISTORY_INFLIGHT.pop(cache_key, None)
|
| 1641 |
+
|
| 1642 |
+
|
| 1643 |
+
def _is_synthetic_symbol(symbol: str) -> bool:
|
| 1644 |
+
return symbol in SYNTHETIC_SYMBOLS
|
| 1645 |
+
|
| 1646 |
+
|
| 1647 |
+
def _extract_candle_value(candle: Dict[str, Any], field_name: str) -> float:
|
| 1648 |
+
return float(candle[field_name])
|
| 1649 |
+
|
| 1650 |
+
|
| 1651 |
+
def _combine_component_candles(
|
| 1652 |
+
spec: SyntheticComponentSpec,
|
| 1653 |
+
left_candle: Dict[str, Any],
|
| 1654 |
+
right_candle: Optional[Dict[str, Any]] = None,
|
| 1655 |
+
use_body_only_extrema: bool = False,
|
| 1656 |
+
) -> Dict[str, float]:
|
| 1657 |
+
if spec.mode == "direct":
|
| 1658 |
+
open_value = _extract_candle_value(left_candle, "open")
|
| 1659 |
+
close_value = _extract_candle_value(left_candle, "close")
|
| 1660 |
+
if use_body_only_extrema:
|
| 1661 |
+
return {
|
| 1662 |
+
"open": open_value,
|
| 1663 |
+
"high": max(open_value, close_value),
|
| 1664 |
+
"low": min(open_value, close_value),
|
| 1665 |
+
"close": close_value,
|
| 1666 |
+
}
|
| 1667 |
+
return {
|
| 1668 |
+
"open": open_value,
|
| 1669 |
+
"high": _extract_candle_value(left_candle, "high"),
|
| 1670 |
+
"low": _extract_candle_value(left_candle, "low"),
|
| 1671 |
+
"close": close_value,
|
| 1672 |
+
}
|
| 1673 |
+
|
| 1674 |
+
if spec.mode == "inverse":
|
| 1675 |
+
lo = _extract_candle_value(left_candle, "open")
|
| 1676 |
+
lh = _extract_candle_value(left_candle, "high")
|
| 1677 |
+
ll = _extract_candle_value(left_candle, "low")
|
| 1678 |
+
lc = _extract_candle_value(left_candle, "close")
|
| 1679 |
+
if min(lo, lh, ll, lc) <= 0:
|
| 1680 |
+
raise ValueError(f"Synthetic component {spec.name} has non-positive inverse values")
|
| 1681 |
+
open_value = 1.0 / lo
|
| 1682 |
+
close_value = 1.0 / lc
|
| 1683 |
+
if use_body_only_extrema:
|
| 1684 |
+
return {
|
| 1685 |
+
"open": open_value,
|
| 1686 |
+
"high": max(open_value, close_value),
|
| 1687 |
+
"low": min(open_value, close_value),
|
| 1688 |
+
"close": close_value,
|
| 1689 |
+
}
|
| 1690 |
+
return {
|
| 1691 |
+
"open": open_value,
|
| 1692 |
+
"high": 1.0 / ll,
|
| 1693 |
+
"low": 1.0 / lh,
|
| 1694 |
+
"close": close_value,
|
| 1695 |
+
}
|
| 1696 |
+
|
| 1697 |
+
if right_candle is None:
|
| 1698 |
+
raise ValueError(f"Synthetic component {spec.name} requires a right candle")
|
| 1699 |
+
|
| 1700 |
+
lo = _extract_candle_value(left_candle, "open")
|
| 1701 |
+
lh = _extract_candle_value(left_candle, "high")
|
| 1702 |
+
ll = _extract_candle_value(left_candle, "low")
|
| 1703 |
+
lc = _extract_candle_value(left_candle, "close")
|
| 1704 |
+
|
| 1705 |
+
ro = _extract_candle_value(right_candle, "open")
|
| 1706 |
+
rh = _extract_candle_value(right_candle, "high")
|
| 1707 |
+
rl = _extract_candle_value(right_candle, "low")
|
| 1708 |
+
rc = _extract_candle_value(right_candle, "close")
|
| 1709 |
+
|
| 1710 |
+
if spec.mode == "product":
|
| 1711 |
+
open_value = lo * ro
|
| 1712 |
+
close_value = lc * rc
|
| 1713 |
+
if use_body_only_extrema:
|
| 1714 |
+
return {
|
| 1715 |
+
"open": open_value,
|
| 1716 |
+
"high": max(open_value, close_value),
|
| 1717 |
+
"low": min(open_value, close_value),
|
| 1718 |
+
"close": close_value,
|
| 1719 |
+
}
|
| 1720 |
+
return {
|
| 1721 |
+
"open": open_value,
|
| 1722 |
+
"high": lh * rh,
|
| 1723 |
+
"low": ll * rl,
|
| 1724 |
+
"close": close_value,
|
| 1725 |
+
}
|
| 1726 |
+
|
| 1727 |
+
if spec.mode == "ratio":
|
| 1728 |
+
if min(ro, rh, rl, rc) <= 0:
|
| 1729 |
+
raise ValueError(f"Synthetic component {spec.name} has non-positive divisor values")
|
| 1730 |
+
open_value = lo / ro
|
| 1731 |
+
close_value = lc / rc
|
| 1732 |
+
if use_body_only_extrema:
|
| 1733 |
+
return {
|
| 1734 |
+
"open": open_value,
|
| 1735 |
+
"high": max(open_value, close_value),
|
| 1736 |
+
"low": min(open_value, close_value),
|
| 1737 |
+
"close": close_value,
|
| 1738 |
+
}
|
| 1739 |
+
return {
|
| 1740 |
+
"open": open_value,
|
| 1741 |
+
"high": lh / rl,
|
| 1742 |
+
"low": ll / rh,
|
| 1743 |
+
"close": close_value,
|
| 1744 |
+
}
|
| 1745 |
+
|
| 1746 |
+
raise ValueError(f"Unsupported synthetic component mode: {spec.mode}")
|
| 1747 |
+
|
| 1748 |
+
|
| 1749 |
+
def _weighted_geometric_mean(
|
| 1750 |
+
values: List[Tuple[float, float]],
|
| 1751 |
+
scale: float,
|
| 1752 |
+
alpha: float,
|
| 1753 |
+
) -> float:
|
| 1754 |
+
sum_ln = 0.0
|
| 1755 |
+
sum_weight = 0.0
|
| 1756 |
+
for value, weight in values:
|
| 1757 |
+
if value <= 0 or weight <= 0:
|
| 1758 |
+
continue
|
| 1759 |
+
sum_ln += weight * math.log(value)
|
| 1760 |
+
sum_weight += weight
|
| 1761 |
+
if sum_weight <= 0:
|
| 1762 |
+
raise ValueError("Synthetic symbol received no valid positive component values")
|
| 1763 |
+
return scale * math.exp(alpha * (sum_ln / sum_weight))
|
| 1764 |
+
|
| 1765 |
+
|
| 1766 |
+
def _build_synthetic_ohlc(
|
| 1767 |
+
component_values: Dict[str, Dict[str, float]],
|
| 1768 |
+
config: SyntheticSymbolConfig,
|
| 1769 |
+
) -> Dict[str, float]:
|
| 1770 |
+
open_value = _weighted_geometric_mean(
|
| 1771 |
+
[(component_values[spec.name]["open"], spec.weight) for spec in config.components if spec.enabled],
|
| 1772 |
+
config.scale,
|
| 1773 |
+
config.alpha,
|
| 1774 |
+
)
|
| 1775 |
+
close_value = _weighted_geometric_mean(
|
| 1776 |
+
[(component_values[spec.name]["close"], spec.weight) for spec in config.components if spec.enabled],
|
| 1777 |
+
config.scale,
|
| 1778 |
+
config.alpha,
|
| 1779 |
+
)
|
| 1780 |
+
high_raw = _weighted_geometric_mean(
|
| 1781 |
+
[(component_values[spec.name]["high"], spec.weight) for spec in config.components if spec.enabled],
|
| 1782 |
+
config.scale,
|
| 1783 |
+
config.alpha,
|
| 1784 |
+
)
|
| 1785 |
+
low_raw = _weighted_geometric_mean(
|
| 1786 |
+
[(component_values[spec.name]["low"], spec.weight) for spec in config.components if spec.enabled],
|
| 1787 |
+
config.scale,
|
| 1788 |
+
config.alpha,
|
| 1789 |
+
)
|
| 1790 |
+
|
| 1791 |
+
midpoint = (open_value + close_value) / 2.0
|
| 1792 |
+
high_value = midpoint + (high_raw - midpoint) * config.wick_shrink
|
| 1793 |
+
low_value = midpoint + (low_raw - midpoint) * config.wick_shrink
|
| 1794 |
+
|
| 1795 |
+
return {
|
| 1796 |
+
"open": open_value,
|
| 1797 |
+
"high": max(high_value, open_value, close_value),
|
| 1798 |
+
"low": min(low_value, open_value, close_value),
|
| 1799 |
+
"close": close_value,
|
| 1800 |
+
}
|
| 1801 |
+
|
| 1802 |
+
|
| 1803 |
+
async def _build_synthetic_symbol_history(
|
| 1804 |
+
symbol: str,
|
| 1805 |
+
interval: str,
|
| 1806 |
+
fetch_limit: int,
|
| 1807 |
+
cache_key: str,
|
| 1808 |
+
) -> Tuple[List[Dict[str, Any]], str]:
|
| 1809 |
+
config = SYNTHETIC_SYMBOLS[symbol]
|
| 1810 |
+
headroom = max(50, fetch_limit // 2)
|
| 1811 |
+
component_limit = min(2000, fetch_limit + headroom)
|
| 1812 |
+
required_symbols = {
|
| 1813 |
+
spec.left_symbol
|
| 1814 |
+
for spec in config.components
|
| 1815 |
+
if spec.enabled
|
| 1816 |
+
} | {
|
| 1817 |
+
spec.right_symbol
|
| 1818 |
+
for spec in config.components
|
| 1819 |
+
if spec.enabled and spec.right_symbol
|
| 1820 |
+
}
|
| 1821 |
+
|
| 1822 |
+
minimum_required = min(20, fetch_limit)
|
| 1823 |
+
preferred_sources = (
|
| 1824 |
+
["yfinance", "twelvedata", "finnhub"]
|
| 1825 |
+
if interval in {"1d", "1w"}
|
| 1826 |
+
else ["twelvedata", "finnhub", "yfinance"]
|
| 1827 |
+
)
|
| 1828 |
+
|
| 1829 |
+
component_rows: Dict[str, List[Dict[str, Any]]] = {}
|
| 1830 |
+
component_sources: Dict[str, str] = {}
|
| 1831 |
+
common_times: List[int] = []
|
| 1832 |
+
last_error_messages: List[str] = []
|
| 1833 |
+
use_body_only_extrema = interval not in {"1d", "1w"}
|
| 1834 |
+
|
| 1835 |
+
async def _load_component_rows(
|
| 1836 |
+
source_name: str,
|
| 1837 |
+
component_symbol: str,
|
| 1838 |
+
) -> Tuple[str, Optional[List[Dict[str, Any]]], Optional[str]]:
|
| 1839 |
+
if source_name not in SYMBOLS[component_symbol].mappings:
|
| 1840 |
+
return component_symbol, None, f"{component_symbol}: missing {source_name} mapping"
|
| 1841 |
+
|
| 1842 |
+
try:
|
| 1843 |
+
rows = await _fetch_historical_from_source_cached(
|
| 1844 |
+
source_name,
|
| 1845 |
+
component_symbol,
|
| 1846 |
+
interval,
|
| 1847 |
+
component_limit,
|
| 1848 |
+
)
|
| 1849 |
+
return component_symbol, rows, None
|
| 1850 |
+
except Exception as exc:
|
| 1851 |
+
return component_symbol, None, f"{component_symbol}: {exc}"
|
| 1852 |
+
|
| 1853 |
+
for source in preferred_sources:
|
| 1854 |
+
current_rows: Dict[str, List[Dict[str, Any]]] = {}
|
| 1855 |
+
current_sources: Dict[str, str] = {}
|
| 1856 |
+
source_errors: List[str] = []
|
| 1857 |
+
|
| 1858 |
+
component_results = await asyncio.gather(
|
| 1859 |
+
*[
|
| 1860 |
+
_load_component_rows(source, component_symbol)
|
| 1861 |
+
for component_symbol in sorted(required_symbols)
|
| 1862 |
+
]
|
| 1863 |
+
)
|
| 1864 |
+
for component_symbol, rows, error_message in component_results:
|
| 1865 |
+
if error_message:
|
| 1866 |
+
source_errors.append(error_message)
|
| 1867 |
+
continue
|
| 1868 |
+
if rows is None:
|
| 1869 |
+
source_errors.append(f"{component_symbol}: empty rows")
|
| 1870 |
+
continue
|
| 1871 |
+
current_rows[component_symbol] = rows
|
| 1872 |
+
current_sources[component_symbol] = source
|
| 1873 |
+
|
| 1874 |
+
if source_errors:
|
| 1875 |
+
last_error_messages = source_errors
|
| 1876 |
+
continue
|
| 1877 |
+
|
| 1878 |
+
time_sets = [
|
| 1879 |
+
{int(row["time"]) for row in rows}
|
| 1880 |
+
for rows in current_rows.values()
|
| 1881 |
+
if rows
|
| 1882 |
+
]
|
| 1883 |
+
current_common_times = sorted(set.intersection(*time_sets)) if time_sets else []
|
| 1884 |
+
if len(current_common_times) < minimum_required:
|
| 1885 |
+
last_error_messages = [
|
| 1886 |
+
f"{source}: aligned={len(current_common_times)} required>={minimum_required}"
|
| 1887 |
+
]
|
| 1888 |
+
continue
|
| 1889 |
+
|
| 1890 |
+
component_rows = current_rows
|
| 1891 |
+
component_sources = current_sources
|
| 1892 |
+
common_times = current_common_times
|
| 1893 |
+
break
|
| 1894 |
+
|
| 1895 |
+
if len(common_times) < minimum_required:
|
| 1896 |
+
raise HTTPException(
|
| 1897 |
+
status_code=502,
|
| 1898 |
+
detail={
|
| 1899 |
+
"message": f"Insufficient aligned candles to build synthetic symbol {symbol}",
|
| 1900 |
+
"errors": last_error_messages,
|
| 1901 |
+
},
|
| 1902 |
+
)
|
| 1903 |
+
|
| 1904 |
+
candles_by_symbol = {
|
| 1905 |
+
component_symbol: {int(row["time"]): row for row in rows}
|
| 1906 |
+
for component_symbol, rows in component_rows.items()
|
| 1907 |
+
}
|
| 1908 |
+
|
| 1909 |
+
synthetic_rows: List[Dict[str, Any]] = []
|
| 1910 |
+
for timestamp in common_times[-fetch_limit:]:
|
| 1911 |
+
component_values: Dict[str, Dict[str, float]] = {}
|
| 1912 |
+
for spec in config.components:
|
| 1913 |
+
if not spec.enabled:
|
| 1914 |
+
continue
|
| 1915 |
+
left_candle = candles_by_symbol[spec.left_symbol][timestamp]
|
| 1916 |
+
right_candle = (
|
| 1917 |
+
candles_by_symbol[spec.right_symbol][timestamp]
|
| 1918 |
+
if spec.right_symbol
|
| 1919 |
+
else None
|
| 1920 |
+
)
|
| 1921 |
+
component_values[spec.name] = _combine_component_candles(
|
| 1922 |
+
spec,
|
| 1923 |
+
left_candle,
|
| 1924 |
+
right_candle,
|
| 1925 |
+
use_body_only_extrema=use_body_only_extrema,
|
| 1926 |
+
)
|
| 1927 |
+
|
| 1928 |
+
synthetic_ohlc = _build_synthetic_ohlc(component_values, config)
|
| 1929 |
+
synthetic_rows.append(
|
| 1930 |
+
{
|
| 1931 |
+
"time": timestamp,
|
| 1932 |
+
"open": round(float(synthetic_ohlc["open"]), 8),
|
| 1933 |
+
"high": round(float(synthetic_ohlc["high"]), 8),
|
| 1934 |
+
"low": round(float(synthetic_ohlc["low"]), 8),
|
| 1935 |
+
"close": round(float(synthetic_ohlc["close"]), 8),
|
| 1936 |
+
"volume": 0.0,
|
| 1937 |
+
}
|
| 1938 |
+
)
|
| 1939 |
+
|
| 1940 |
+
source = "synthetic:" + ",".join(sorted(set(component_sources.values())))
|
| 1941 |
+
historical_cache.set(cache_key, (synthetic_rows, source), ttl_seconds=interval_ttl(interval))
|
| 1942 |
+
return synthetic_rows, source
|
| 1943 |
|
| 1944 |
|
| 1945 |
def _get_source_priority(symbol: str) -> List[str]:
|
|
|
|
| 1954 |
fetch_limit: int,
|
| 1955 |
cache_key: str,
|
| 1956 |
) -> Tuple[List[Dict[str, Any]], str]:
|
| 1957 |
+
if _is_synthetic_symbol(symbol):
|
| 1958 |
+
return await _build_synthetic_symbol_history(symbol, interval, fetch_limit, cache_key)
|
| 1959 |
+
|
| 1960 |
priority = _get_source_priority(symbol)
|
| 1961 |
errors: List[str] = []
|
| 1962 |
|
|
|
|
| 1983 |
historical_cache.set(cache_key, (data, source), ttl_seconds=interval_ttl(interval))
|
| 1984 |
return data, source
|
| 1985 |
errors.append(f"{source}: insufficient ({len(data)} candles)")
|
| 1986 |
+
except HTTPException as ex:
|
| 1987 |
+
errors.append(f"{source}: HTTP {ex.status_code} {ex.detail}")
|
| 1988 |
+
logger.warning(
|
| 1989 |
+
"[fetch_historical] %s/%s %s HTTP %s: %s",
|
| 1990 |
+
symbol,
|
| 1991 |
+
interval,
|
| 1992 |
+
source,
|
| 1993 |
+
ex.status_code,
|
| 1994 |
+
ex.detail,
|
| 1995 |
+
)
|
| 1996 |
+
continue
|
| 1997 |
except Exception as ex:
|
| 1998 |
errors.append(f"{source}: {ex}")
|
| 1999 |
logger.warning("[fetch_historical] %s/%s %s: %s", symbol, interval, source, ex)
|
|
|
|
| 2053 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2054 |
# Real-time Ticker (last price + 24h stats)
|
| 2055 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2056 |
+
async def fetch_ticker(symbol: str, interval: Optional[str] = None) -> Dict[str, Any]:
|
| 2057 |
+
interval_key = interval or "default"
|
| 2058 |
+
cache_key = f"ticker:{symbol}:{interval_key}"
|
| 2059 |
+
cached = ticker_cache.get(cache_key)
|
| 2060 |
if cached:
|
| 2061 |
return cached
|
| 2062 |
|
| 2063 |
+
inflight_key = cache_key
|
| 2064 |
inflight_task = _TICKER_INFLIGHT.get(inflight_key)
|
| 2065 |
if inflight_task is not None:
|
| 2066 |
return await inflight_task
|
| 2067 |
|
| 2068 |
async def _run() -> Dict[str, Any]:
|
| 2069 |
+
if _is_synthetic_symbol(symbol):
|
| 2070 |
+
synthetic_interval = interval or "1d"
|
| 2071 |
+
rows, source = await fetch_historical(symbol, synthetic_interval, 2, min_context=2)
|
| 2072 |
+
current_row = rows[-1]
|
| 2073 |
+
previous_row = rows[-2] if len(rows) > 1 else current_row
|
| 2074 |
+
current_price = float(current_row["close"])
|
| 2075 |
+
previous_close = float(previous_row["close"])
|
| 2076 |
+
high_24h = float(current_row["high"])
|
| 2077 |
+
low_24h = float(current_row["low"])
|
| 2078 |
+
change_value = current_price - previous_close
|
| 2079 |
+
result = {
|
| 2080 |
+
"symbol": symbol,
|
| 2081 |
+
"price": current_price,
|
| 2082 |
+
"change": change_value,
|
| 2083 |
+
"change_pct": ((change_value / previous_close) * 100.0) if previous_close else 0.0,
|
| 2084 |
+
"high_24h": high_24h,
|
| 2085 |
+
"low_24h": low_24h,
|
| 2086 |
+
"volume_24h": 0.0,
|
| 2087 |
+
"source": source,
|
| 2088 |
+
"timestamp": int(time.time()),
|
| 2089 |
+
}
|
| 2090 |
+
ticker_cache.set(cache_key, result, ttl_seconds=10)
|
| 2091 |
+
return result
|
| 2092 |
+
|
| 2093 |
cfg = SYMBOLS[symbol]
|
| 2094 |
priority = _get_source_priority(symbol)
|
| 2095 |
client = await GlobalHTTPClient.get_client()
|
|
|
|
| 2119 |
await _rate_limit("twelvedata")
|
| 2120 |
r = await client.get(
|
| 2121 |
"https://api.twelvedata.com/quote",
|
| 2122 |
+
params={"symbol": cfg.mappings["twelvedata"], "apikey": twelvedata_pool.next_key()},
|
| 2123 |
timeout=10.0,
|
| 2124 |
)
|
| 2125 |
d = r.json()
|
|
|
|
| 2199 |
continue
|
| 2200 |
|
| 2201 |
res.update({"symbol": symbol, "timestamp": int(time.time())})
|
| 2202 |
+
ttl = 5 if cfg.category == "Crypto" else (10 if cfg.category in ("CαΊ·p tiα»n", "Chα» sα»", "Real Strength") else 30)
|
| 2203 |
+
ticker_cache.set(cache_key, res, ttl_seconds=ttl)
|
| 2204 |
return res
|
| 2205 |
except Exception as ex:
|
| 2206 |
logger.debug("[ticker] %s/%s failed: %s", symbol, source, ex)
|
|
|
|
| 2208 |
|
| 2209 |
raise HTTPException(status_code=502, detail=f"Ticker failed for {symbol} after trying {priority}")
|
| 2210 |
|
| 2211 |
+
task = asyncio.create_task(_run(), name=f"ticker:{symbol}:{interval_key}")
|
| 2212 |
_TICKER_INFLIGHT[inflight_key] = task
|
| 2213 |
try:
|
| 2214 |
return await task
|
|
|
|
| 4404 |
self._predictor: Optional[Any] = None
|
| 4405 |
self._loaded = False
|
| 4406 |
self._lock: Optional[asyncio.Lock] = None
|
| 4407 |
+
self._predict_lock: Optional[asyncio.Lock] = None
|
| 4408 |
|
| 4409 |
async def _get_lock(self) -> asyncio.Lock:
|
| 4410 |
if self._lock is None:
|
| 4411 |
self._lock = asyncio.Lock()
|
| 4412 |
return self._lock
|
| 4413 |
|
| 4414 |
+
async def _get_predict_lock(self) -> asyncio.Lock:
|
| 4415 |
+
if self._predict_lock is None:
|
| 4416 |
+
self._predict_lock = asyncio.Lock()
|
| 4417 |
+
return self._predict_lock
|
| 4418 |
+
|
| 4419 |
@property
|
| 4420 |
def is_ready(self) -> bool:
|
| 4421 |
return self._loaded
|
|
|
|
| 4430 |
return CLIP_DEFAULT
|
| 4431 |
return getattr(self._predictor, "clip", CLIP_DEFAULT)
|
| 4432 |
|
| 4433 |
+
@staticmethod
|
| 4434 |
+
def _collapse_tokenizer_to_single_ohlc4_channel(tokenizer: Any) -> Any:
|
| 4435 |
+
"""
|
| 4436 |
+
Convert the 6-channel public Kronos tokenizer into a true 1-channel
|
| 4437 |
+
tokenizer for OHLC4 inference.
|
| 4438 |
+
|
| 4439 |
+
The encoder-side projection preserves the previous replicated-OHLC4
|
| 4440 |
+
behaviour exactly by summing the O/H/L/C input weights, because the old
|
| 4441 |
+
wrapper fed the same OHLC4 value into all four price channels.
|
| 4442 |
+
|
| 4443 |
+
The decoder-side projection emits a single OHLC4 channel by averaging
|
| 4444 |
+
the original O/H/L/C output heads.
|
| 4445 |
+
"""
|
| 4446 |
+
d_in = int(getattr(tokenizer, "d_in", 0) or 0)
|
| 4447 |
+
if d_in == 1:
|
| 4448 |
+
return tokenizer
|
| 4449 |
+
if d_in != 6:
|
| 4450 |
+
raise ValueError(f"Unsupported Kronos tokenizer d_in={d_in}; expected 6 for adapter collapse")
|
| 4451 |
+
|
| 4452 |
+
device = tokenizer.embed.weight.device
|
| 4453 |
+
dtype = tokenizer.embed.weight.dtype
|
| 4454 |
+
|
| 4455 |
+
collapsed_embed = torch.nn.Linear(1, tokenizer.d_model, bias=tokenizer.embed.bias is not None).to(device=device, dtype=dtype)
|
| 4456 |
+
collapsed_head = torch.nn.Linear(tokenizer.d_model, 1, bias=tokenizer.head.bias is not None).to(device=device, dtype=dtype)
|
| 4457 |
+
|
| 4458 |
+
with torch.no_grad():
|
| 4459 |
+
collapsed_embed.weight.copy_(tokenizer.embed.weight[:, :4].sum(dim=1, keepdim=True))
|
| 4460 |
+
if tokenizer.embed.bias is not None and collapsed_embed.bias is not None:
|
| 4461 |
+
collapsed_embed.bias.copy_(tokenizer.embed.bias)
|
| 4462 |
+
|
| 4463 |
+
collapsed_head.weight.copy_(tokenizer.head.weight[:4].mean(dim=0, keepdim=True))
|
| 4464 |
+
if tokenizer.head.bias is not None and collapsed_head.bias is not None:
|
| 4465 |
+
collapsed_head.bias.copy_(tokenizer.head.bias[:4].mean().reshape(1))
|
| 4466 |
+
|
| 4467 |
+
tokenizer.embed = collapsed_embed
|
| 4468 |
+
tokenizer.head = collapsed_head
|
| 4469 |
+
tokenizer.d_in = 1
|
| 4470 |
+
return tokenizer
|
| 4471 |
+
|
| 4472 |
async def _lazy_load(self) -> None:
|
| 4473 |
if self._loaded:
|
| 4474 |
return
|
|
|
|
| 4484 |
else "cpu")
|
| 4485 |
logger.info("[Kronos] Loading on %s β¦", device)
|
| 4486 |
tokenizer = await asyncio.to_thread(KronosTokenizer.from_pretrained, "NeoQuasar/Kronos-Tokenizer-base")
|
| 4487 |
+
tokenizer = self._collapse_tokenizer_to_single_ohlc4_channel(tokenizer)
|
| 4488 |
model = await asyncio.to_thread(Kronos.from_pretrained, self.MODEL_NAME)
|
| 4489 |
self._predictor = KronosPredictor(model, tokenizer, device=device, max_context=self.MAX_CONTEXT)
|
| 4490 |
self._loaded = True
|
|
|
|
| 4494 |
raise HTTPException(status_code=500, detail=f"Kronos init failed: {ex}")
|
| 4495 |
|
| 4496 |
@staticmethod
|
| 4497 |
+
def _prepare_feature_frame(df: pd.DataFrame) -> pd.DataFrame:
|
| 4498 |
+
"""
|
| 4499 |
+
Prepare a true 1-channel OHLC4 frame for Kronos inference.
|
| 4500 |
+
"""
|
| 4501 |
+
required_price_cols = ["open", "high", "low", "close"]
|
| 4502 |
+
ohlc4 = df[required_price_cols].mean(axis=1).astype(np.float32)
|
| 4503 |
+
return pd.DataFrame({"ohlc4": ohlc4}, index=df.index).astype(np.float32)
|
| 4504 |
+
|
| 4505 |
+
@staticmethod
|
| 4506 |
+
def _normalize_feature_matrix(
|
| 4507 |
+
x: np.ndarray,
|
| 4508 |
+
clip: float,
|
| 4509 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 4510 |
+
"""
|
| 4511 |
+
Use the same normalization contract as KronosPredictor.predict():
|
| 4512 |
+
std is stabilized by +1e-5 rather than replacing zero-std columns with 1.0.
|
| 4513 |
+
"""
|
| 4514 |
+
x_mean = np.mean(x, axis=0).astype(np.float32)
|
| 4515 |
+
x_scale = (np.std(x, axis=0) + 1e-5).astype(np.float32)
|
| 4516 |
+
x_norm = np.clip((x - x_mean) / x_scale, -clip, clip).astype(np.float32)
|
| 4517 |
+
return x_norm, x_mean, x_scale
|
| 4518 |
|
| 4519 |
async def forecast(self, df: pd.DataFrame, x_timestamp: pd.Series,
|
| 4520 |
y_timestamp: pd.Series, horizon: int,
|
|
|
|
| 4527 |
if not isinstance(y_timestamp, pd.Series):
|
| 4528 |
y_timestamp = pd.Series(y_timestamp.values if hasattr(y_timestamp, "values") else y_timestamp)
|
| 4529 |
|
| 4530 |
+
prepared_df = self._prepare_feature_frame(df)
|
| 4531 |
+
x = prepared_df[["ohlc4"]].values.astype(np.float32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4532 |
|
| 4533 |
x_stamp = calc_time_stamps(x_timestamp).values.astype(np.float32)
|
| 4534 |
y_stamp = calc_time_stamps(y_timestamp).values.astype(np.float32)
|
| 4535 |
|
| 4536 |
+
x_norm, x_mean, x_scale = self._normalize_feature_matrix(x, self._clip)
|
|
|
|
|
|
|
|
|
|
| 4537 |
|
| 4538 |
x_norm = x_norm[np.newaxis, :]
|
| 4539 |
x_stamp = x_stamp[np.newaxis, :]
|
| 4540 |
y_stamp = y_stamp[np.newaxis, :]
|
| 4541 |
|
| 4542 |
t0 = time.time()
|
| 4543 |
+
predict_lock = await self._get_predict_lock()
|
| 4544 |
+
async with predict_lock:
|
| 4545 |
+
samples = await asyncio.to_thread(
|
| 4546 |
+
self._predictor.generate,
|
| 4547 |
+
x=x_norm, x_stamp=x_stamp, y_stamp=y_stamp,
|
| 4548 |
+
pred_len=horizon, T=1.0, top_k=0, top_p=0.9,
|
| 4549 |
+
sample_count=sample_count, verbose=False, return_samples=True,
|
| 4550 |
+
)
|
| 4551 |
logger.info("[Kronos] %.2fs | horizon=%d samples=%d ctx=%d",
|
| 4552 |
time.time() - t0, horizon, sample_count, len(df))
|
| 4553 |
|
| 4554 |
if "cuda" in self.device:
|
| 4555 |
torch.cuda.empty_cache()
|
| 4556 |
|
| 4557 |
+
ohlc4_samples = np.asarray(samples[0, :, :, 0], dtype=float)
|
| 4558 |
# Some Kronos checkpoints return the full decoded sequence rather than
|
| 4559 |
# only the requested pred_len. Keep the most recent horizon window so
|
| 4560 |
# downstream logic always receives a forecast-length vector.
|
| 4561 |
+
if ohlc4_samples.shape[1] > horizon:
|
| 4562 |
+
ohlc4_samples = ohlc4_samples[:, -horizon:]
|
| 4563 |
+
elif ohlc4_samples.shape[1] < horizon:
|
| 4564 |
+
pad_width = horizon - ohlc4_samples.shape[1]
|
| 4565 |
+
ohlc4_samples = np.pad(ohlc4_samples, ((0, 0), (0, pad_width)), mode="edge")
|
| 4566 |
|
| 4567 |
+
ohlc4_samples = ohlc4_samples * float(x_scale[0]) + float(x_mean[0])
|
| 4568 |
+
p10 = np.percentile(ohlc4_samples, 10, axis=0)
|
| 4569 |
+
p50 = np.percentile(ohlc4_samples, 50, axis=0)
|
| 4570 |
+
p90 = np.percentile(ohlc4_samples, 90, axis=0)
|
| 4571 |
|
| 4572 |
return {
|
| 4573 |
+
"p10": p10,
|
| 4574 |
+
"p50": p50,
|
| 4575 |
+
"p90": p90,
|
| 4576 |
"model_name": self.MODEL_NAME,
|
| 4577 |
"context_length": len(df),
|
| 4578 |
+
"output_horizon": int(ohlc4_samples.shape[1]),
|
| 4579 |
+
"input_semantics": {
|
| 4580 |
+
"feature_channels": ["ohlc4"],
|
| 4581 |
+
"active_forecast_channels": ["ohlc4"],
|
| 4582 |
+
"ignored_channels": [],
|
| 4583 |
+
"price_mode": "ohlc4_single_channel",
|
| 4584 |
+
"base_signal": "ohlc4",
|
| 4585 |
+
"volume_mode": "omitted",
|
| 4586 |
+
"amount_mode": "omitted",
|
| 4587 |
+
"adapter_mode": "tokenizer_6ch_to_1ch_ohlc4",
|
| 4588 |
+
"normalization": "std_plus_epsilon_1e-5",
|
| 4589 |
+
},
|
| 4590 |
+
"output_semantics": {
|
| 4591 |
+
"forecast_channel": "ohlc4",
|
| 4592 |
+
"forecast_mode": "single_future_ohlc4_line",
|
| 4593 |
+
"quantile_fields": ["p10", "p50", "p90"],
|
| 4594 |
+
"candle_projection": "omitted",
|
| 4595 |
+
},
|
| 4596 |
}
|
| 4597 |
except Exception as ex:
|
| 4598 |
logger.error("[Kronos] Forecast failed: %s", ex, exc_info=True)
|
|
|
|
| 4602 |
forecaster = KronosForecaster()
|
| 4603 |
|
| 4604 |
|
| 4605 |
+
def _is_kronos_shape_mismatch_error(exc: Exception) -> bool:
|
| 4606 |
+
detail = getattr(exc, "detail", exc)
|
| 4607 |
+
text = str(detail)
|
| 4608 |
+
return (
|
| 4609 |
+
"size of tensor" in text
|
| 4610 |
+
and "must match" in text
|
| 4611 |
+
)
|
| 4612 |
+
|
| 4613 |
+
|
| 4614 |
|
| 4615 |
# MODULE: Analysis Engine v2.0 (Relocated and Activated)
|
| 4616 |
# Legacy placeholders removed to avoid duplication with logic at line 1884.
|
|
|
|
| 4676 |
if symbol not in SYMBOLS:
|
| 4677 |
await websocket.close(code=1008, reason=f"Unknown symbol: {symbol}")
|
| 4678 |
return
|
| 4679 |
+
interval = websocket.query_params.get("interval") or "1d"
|
| 4680 |
|
| 4681 |
try:
|
| 4682 |
await ws_manager.connect(websocket, symbol)
|
|
|
|
| 4687 |
break
|
| 4688 |
|
| 4689 |
# 2. Fetch fresh price
|
| 4690 |
+
ticker = await fetch_ticker(symbol, interval=interval)
|
| 4691 |
|
| 4692 |
# 3. Final state check before send
|
| 4693 |
if websocket.client_state == WebSocketState.CONNECTED:
|
|
|
|
| 5101 |
|
| 5102 |
|
| 5103 |
@app.get("/api/ticker/{symbol}")
|
| 5104 |
+
async def get_ticker(symbol: str, interval: Optional[str] = None) -> Dict[str, Any]:
|
| 5105 |
symbol = _get_canonical_symbol(symbol)
|
| 5106 |
if symbol not in SYMBOLS:
|
| 5107 |
raise HTTPException(404, f"Unknown symbol: {symbol}")
|
| 5108 |
+
return await fetch_ticker(symbol, interval=interval)
|
| 5109 |
|
| 5110 |
|
| 5111 |
# ββ Watchlist (batch ticker) ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 5135 |
}
|
| 5136 |
|
| 5137 |
|
| 5138 |
+
def _forecast_cache_key(symbol: str, interval: str, horizon: int) -> str:
|
| 5139 |
+
return f"forecast_{_cache_prefix(symbol, interval)}{horizon}"
|
| 5140 |
+
|
| 5141 |
+
|
| 5142 |
+
def _forecast_payload_is_current(cached: Optional[Dict[str, Any]]) -> bool:
|
| 5143 |
+
if not isinstance(cached, dict):
|
| 5144 |
+
return False
|
| 5145 |
+
if "forecast_candles" in cached:
|
| 5146 |
+
return False
|
| 5147 |
+
|
| 5148 |
+
display = cached.get("display") or {}
|
| 5149 |
+
if (
|
| 5150 |
+
display.get("mode") != "raw_kronos_ohlc4_line"
|
| 5151 |
+
or display.get("output_mode") != "single_future_ohlc4_line"
|
| 5152 |
+
or display.get("channels") != ["ohlc4"]
|
| 5153 |
+
):
|
| 5154 |
+
return False
|
| 5155 |
+
|
| 5156 |
+
model_meta = cached.get("model") or {}
|
| 5157 |
+
semantics = model_meta.get("input_semantics") or {}
|
| 5158 |
+
output_semantics = model_meta.get("output_semantics") or {}
|
| 5159 |
+
return (
|
| 5160 |
+
semantics.get("volume_mode") == "omitted"
|
| 5161 |
+
and semantics.get("amount_mode") == "omitted"
|
| 5162 |
+
and semantics.get("active_forecast_channels") == ["ohlc4"]
|
| 5163 |
+
and semantics.get("feature_channels") == ["ohlc4"]
|
| 5164 |
+
and semantics.get("price_mode") == "ohlc4_single_channel"
|
| 5165 |
+
and semantics.get("base_signal") == "ohlc4"
|
| 5166 |
+
and semantics.get("adapter_mode") == "tokenizer_6ch_to_1ch_ohlc4"
|
| 5167 |
+
and output_semantics.get("forecast_channel") == "ohlc4"
|
| 5168 |
+
and output_semantics.get("forecast_mode") == "single_future_ohlc4_line"
|
| 5169 |
+
and output_semantics.get("candle_projection") == "omitted"
|
| 5170 |
+
)
|
| 5171 |
+
|
| 5172 |
+
|
| 5173 |
+
def _load_cached_forecast_response(cache_key: str, interval: str) -> Optional[Dict[str, Any]]:
|
| 5174 |
+
cached = forecast_cache.get(cache_key)
|
| 5175 |
+
if cached is not None and _forecast_payload_is_current(cached):
|
| 5176 |
+
cached["generated_at"] = int(time.time())
|
| 5177 |
+
cached["cache"] = {"origin": "memory", "refresh_requested": False}
|
| 5178 |
+
return cached
|
| 5179 |
+
|
| 5180 |
+
persisted = persistent_cache.get(cache_key)
|
| 5181 |
+
if persisted is not None and _forecast_payload_is_current(persisted):
|
| 5182 |
+
persisted["from_persistent_cache"] = True
|
| 5183 |
+
forecast_cache.set(cache_key, persisted, ttl_seconds=forecast_ttl(interval))
|
| 5184 |
+
persisted["generated_at"] = int(time.time())
|
| 5185 |
+
persisted["cache"] = {"origin": "persistent", "refresh_requested": False}
|
| 5186 |
+
return persisted
|
| 5187 |
+
|
| 5188 |
+
return None
|
| 5189 |
+
|
| 5190 |
+
|
| 5191 |
+
async def _prepare_forecast_response_payload(
|
| 5192 |
+
symbol: str,
|
| 5193 |
+
interval: str,
|
| 5194 |
+
horizon: int,
|
| 5195 |
+
refresh: bool,
|
| 5196 |
+
cache_origin: str,
|
| 5197 |
+
) -> Dict[str, Any]:
|
| 5198 |
+
data_list, source, indicators = await get_indicators_cached(
|
| 5199 |
+
symbol,
|
| 5200 |
+
interval,
|
| 5201 |
+
FORECAST_CONTEXT,
|
| 5202 |
+
refresh=refresh,
|
| 5203 |
+
min_context=FORECAST_CONTEXT,
|
| 5204 |
+
)
|
| 5205 |
+
if not KRONOS_AVAILABLE:
|
| 5206 |
+
last_ohlc4 = (
|
| 5207 |
+
float(
|
| 5208 |
+
np.mean(
|
| 5209 |
+
[
|
| 5210 |
+
float(data_list[-1]["open"]),
|
| 5211 |
+
float(data_list[-1]["high"]),
|
| 5212 |
+
float(data_list[-1]["low"]),
|
| 5213 |
+
float(data_list[-1]["close"]),
|
| 5214 |
+
]
|
| 5215 |
+
)
|
| 5216 |
+
)
|
| 5217 |
+
if data_list
|
| 5218 |
+
else 0.0
|
| 5219 |
+
)
|
| 5220 |
+
return {
|
| 5221 |
+
"symbol": symbol,
|
| 5222 |
+
"interval": interval,
|
| 5223 |
+
"forecast_rows": [],
|
| 5224 |
+
"error": "AI Forecaster is currently offline or not found in bundle.",
|
| 5225 |
+
"path_checked": KRONOS_PATH,
|
| 5226 |
+
"ai_runtime": {"mode": "local_only", "model": "offline"},
|
| 5227 |
+
"_data_list": data_list,
|
| 5228 |
+
"indicators_snapshot": indicators,
|
| 5229 |
+
"_blended": {"confidence": 0.0, "agreement": False, "scale": 1.0, "model_weight": 0.0, "anchor_weight": 1.0, "model_bias_pct": 0.0},
|
| 5230 |
+
"source": source,
|
| 5231 |
+
"horizon": horizon,
|
| 5232 |
+
"last_close": last_ohlc4,
|
| 5233 |
+
"model": {
|
| 5234 |
+
"name": "offline",
|
| 5235 |
+
"context_length": 0,
|
| 5236 |
+
"quantiles": [0.1, 0.5, 0.9],
|
| 5237 |
+
"cache_version": CACHE_VERSION,
|
| 5238 |
+
"sample_count": 0,
|
| 5239 |
+
"input_semantics": {
|
| 5240 |
+
"feature_channels": ["ohlc4"],
|
| 5241 |
+
"active_forecast_channels": ["ohlc4"],
|
| 5242 |
+
"ignored_channels": [],
|
| 5243 |
+
"price_mode": "ohlc4_single_channel",
|
| 5244 |
+
"base_signal": "ohlc4",
|
| 5245 |
+
"volume_mode": "omitted",
|
| 5246 |
+
"amount_mode": "omitted",
|
| 5247 |
+
"adapter_mode": "tokenizer_6ch_to_1ch_ohlc4",
|
| 5248 |
+
},
|
| 5249 |
+
"output_semantics": {
|
| 5250 |
+
"forecast_channel": "ohlc4",
|
| 5251 |
+
"forecast_mode": "single_future_ohlc4_line",
|
| 5252 |
+
"quantile_fields": ["p10", "p50", "p90"],
|
| 5253 |
+
"candle_projection": "omitted",
|
| 5254 |
+
},
|
| 5255 |
+
},
|
| 5256 |
+
"_cache_origin": cache_origin,
|
| 5257 |
+
}
|
| 5258 |
+
|
| 5259 |
+
if len(data_list) < 40:
|
| 5260 |
+
raise HTTPException(422, "Insufficient historical data for forecasting")
|
| 5261 |
+
|
| 5262 |
+
df_hist = pd.DataFrame(data_list)
|
| 5263 |
+
df_hist["timestamps"] = pd.to_datetime(df_hist["time"], unit="s", utc=True)
|
| 5264 |
+
|
| 5265 |
+
if "amount" not in df_hist.columns or df_hist["amount"].isna().all() or df_hist["amount"].sum() == 0:
|
| 5266 |
+
typical = (df_hist["high"] + df_hist["low"] + df_hist["close"]) / 3
|
| 5267 |
+
df_hist["amount"] = (df_hist["volume"] * typical).fillna(0)
|
| 5268 |
+
else:
|
| 5269 |
+
df_hist["amount"] = df_hist["amount"].fillna(0)
|
| 5270 |
+
|
| 5271 |
+
context_len = min(len(df_hist), KronosForecaster.MAX_CONTEXT)
|
| 5272 |
+
df_context = df_hist.tail(context_len).reset_index(drop=True)
|
| 5273 |
+
|
| 5274 |
+
logger.info("[forecast] %s %s | ctx=%d/%d | horizon=%d", symbol, interval, context_len, len(df_hist), horizon)
|
| 5275 |
+
|
| 5276 |
+
last_time = int(df_hist["time"].iloc[-1])
|
| 5277 |
+
step = STEP_SECONDS[interval]
|
| 5278 |
+
y_timestamps = pd.Series(pd.to_datetime(
|
| 5279 |
+
[last_time + step * (i + 1) for i in range(horizon)], unit="s", utc=True
|
| 5280 |
+
))
|
| 5281 |
+
|
| 5282 |
+
sample_count = 10 if forecaster.device in {"not_loaded", "cpu"} else 15
|
| 5283 |
+
|
| 5284 |
+
async def _run_model(model_df: pd.DataFrame) -> Dict[str, Any]:
|
| 5285 |
+
return await forecaster.forecast(
|
| 5286 |
+
df=model_df[["open", "high", "low", "close", "volume", "amount"]],
|
| 5287 |
+
x_timestamp=model_df["timestamps"],
|
| 5288 |
+
y_timestamp=y_timestamps,
|
| 5289 |
+
horizon=horizon,
|
| 5290 |
+
sample_count=sample_count,
|
| 5291 |
+
)
|
| 5292 |
+
|
| 5293 |
+
try:
|
| 5294 |
+
model_output = await _run_model(df_context)
|
| 5295 |
+
except HTTPException as exc:
|
| 5296 |
+
if not _is_kronos_shape_mismatch_error(exc) or context_len <= 504:
|
| 5297 |
+
raise
|
| 5298 |
+
|
| 5299 |
+
fallback_context_len = min(context_len - 8, 504)
|
| 5300 |
+
fallback_context_len = max(fallback_context_len, min(256, context_len))
|
| 5301 |
+
if fallback_context_len >= context_len:
|
| 5302 |
+
raise
|
| 5303 |
+
|
| 5304 |
+
logger.warning(
|
| 5305 |
+
"[forecast] %s %s | Kronos shape mismatch at ctx=%d, retrying with ctx=%d",
|
| 5306 |
+
symbol,
|
| 5307 |
+
interval,
|
| 5308 |
+
context_len,
|
| 5309 |
+
fallback_context_len,
|
| 5310 |
+
)
|
| 5311 |
+
context_len = fallback_context_len
|
| 5312 |
+
df_context = df_hist.tail(context_len).reset_index(drop=True)
|
| 5313 |
+
model_output = await _run_model(df_context)
|
| 5314 |
+
|
| 5315 |
+
last_ohlc4 = float(df_hist[["open", "high", "low", "close"]].mean(axis=1).iloc[-1])
|
| 5316 |
+
last_close = last_ohlc4
|
| 5317 |
+
analysis_bundle = _build_raw_close_bundle(model_output, last_ohlc4)
|
| 5318 |
+
logger.info(
|
| 5319 |
+
"[forecast] raw-ohlc4-line | %s %s | confidence=%.1f agreement=%s",
|
| 5320 |
+
symbol,
|
| 5321 |
+
interval,
|
| 5322 |
+
analysis_bundle["confidence"],
|
| 5323 |
+
analysis_bundle["agreement"],
|
| 5324 |
+
)
|
| 5325 |
+
|
| 5326 |
+
forecast_rows: List[Dict[str, Any]] = [
|
| 5327 |
+
{"time": last_time, "p10": last_ohlc4, "p50": last_ohlc4, "p90": last_ohlc4, "is_actual": True}
|
| 5328 |
+
]
|
| 5329 |
+
for i in range(horizon):
|
| 5330 |
+
forecast_rows.append({
|
| 5331 |
+
"time": int(last_time + step * (i + 1)),
|
| 5332 |
+
"p10": round(float(analysis_bundle["p10"][i]), 6),
|
| 5333 |
+
"p50": round(float(analysis_bundle["p50"][i]), 6),
|
| 5334 |
+
"p90": round(float(analysis_bundle["p90"][i]), 6),
|
| 5335 |
+
})
|
| 5336 |
+
|
| 5337 |
+
return {
|
| 5338 |
+
"symbol": symbol,
|
| 5339 |
+
"interval": interval,
|
| 5340 |
+
"source": source,
|
| 5341 |
+
"horizon": horizon,
|
| 5342 |
+
"last_close": last_close,
|
| 5343 |
+
"forecast_rows": forecast_rows,
|
| 5344 |
+
"from_persistent_cache": False,
|
| 5345 |
+
"model": {
|
| 5346 |
+
"name": model_output.get("model_name", "Kronos-base"),
|
| 5347 |
+
"context_length": int(model_output.get("context_length", context_len)),
|
| 5348 |
+
"quantiles": [0.1, 0.5, 0.9],
|
| 5349 |
+
"cache_version": CACHE_VERSION,
|
| 5350 |
+
"sample_count": sample_count,
|
| 5351 |
+
"input_semantics": model_output.get("input_semantics", {}),
|
| 5352 |
+
"output_semantics": model_output.get("output_semantics", {}),
|
| 5353 |
+
},
|
| 5354 |
+
"indicators_snapshot": indicators,
|
| 5355 |
+
"_data_list": data_list,
|
| 5356 |
+
"_analysis_bundle": analysis_bundle,
|
| 5357 |
+
"_model_output": model_output,
|
| 5358 |
+
"_cache_origin": cache_origin,
|
| 5359 |
+
"ai_runtime": {
|
| 5360 |
+
"mode": "local_only",
|
| 5361 |
+
"model": str(model_output.get("model_name", "Kronos-base")),
|
| 5362 |
+
"device": forecaster.device,
|
| 5363 |
+
},
|
| 5364 |
+
}
|
| 5365 |
+
|
| 5366 |
+
|
| 5367 |
+
def _build_raw_close_bundle(
|
| 5368 |
+
model_output: Dict[str, Any],
|
| 5369 |
+
last_close: float,
|
| 5370 |
+
) -> Dict[str, Any]:
|
| 5371 |
+
"""Build the close-path bundle directly from raw Kronos output."""
|
| 5372 |
+
raw_p10 = np.array(model_output["p10"], dtype=float)
|
| 5373 |
+
raw_p50 = np.array(model_output["p50"], dtype=float)
|
| 5374 |
+
raw_p90 = np.array(model_output["p90"], dtype=float)
|
| 5375 |
+
|
| 5376 |
+
path_metrics = _forecast_path_metrics(raw_p50, last_close)
|
| 5377 |
+
avg_band_pct = float(
|
| 5378 |
+
np.mean((raw_p90 - raw_p10) / np.maximum(np.abs(raw_p50), 1e-8)) * 100.0
|
| 5379 |
+
) if len(raw_p50) else 0.0
|
| 5380 |
+
band_certainty = math.exp(-avg_band_pct / 4.0)
|
| 5381 |
+
path_consistency = path_metrics["path_consistency"] / 100.0
|
| 5382 |
+
monotonicity = path_metrics["monotonicity"] / 100.0
|
| 5383 |
+
move_pct = abs(path_metrics["final_return_pct"])
|
| 5384 |
+
|
| 5385 |
+
confidence = (
|
| 5386 |
+
28.0
|
| 5387 |
+
+ band_certainty * 34.0
|
| 5388 |
+
+ path_consistency * 18.0
|
| 5389 |
+
+ monotonicity * 12.0
|
| 5390 |
+
+ min(move_pct, 4.0) * 2.0
|
| 5391 |
+
)
|
| 5392 |
+
confidence = max(20.0, min(95.0, confidence))
|
| 5393 |
+
|
| 5394 |
+
final_sign = 0 if abs(path_metrics["final_return_pct"]) < 0.05 else (1 if path_metrics["final_return_pct"] > 0 else -1)
|
| 5395 |
+
weighted_sign = 0 if abs(path_metrics["weighted_return_pct"]) < 0.05 else (1 if path_metrics["weighted_return_pct"] > 0 else -1)
|
| 5396 |
+
agreement = final_sign == 0 or weighted_sign == 0 or final_sign == weighted_sign
|
| 5397 |
+
|
| 5398 |
+
return {
|
| 5399 |
+
"p10": raw_p10,
|
| 5400 |
+
"p50": raw_p50,
|
| 5401 |
+
"p90": raw_p90,
|
| 5402 |
+
"model_weight": 1.0,
|
| 5403 |
+
"anchor_weight": 0.0,
|
| 5404 |
+
"agreement": agreement,
|
| 5405 |
+
"scale": 1.0,
|
| 5406 |
+
"confidence": round(confidence, 2),
|
| 5407 |
+
"model_bias_pct": 0.0,
|
| 5408 |
+
"path_metrics": path_metrics,
|
| 5409 |
+
"mode": "raw_kronos_ohlc4",
|
| 5410 |
+
}
|
| 5411 |
+
|
| 5412 |
+
|
| 5413 |
+
async def _finalize_forecast_response_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 5414 |
+
if payload.get("error"):
|
| 5415 |
+
response = {
|
| 5416 |
+
"symbol": payload["symbol"],
|
| 5417 |
+
"interval": payload["interval"],
|
| 5418 |
+
"forecast": payload.get("forecast_rows", []),
|
| 5419 |
+
"error": payload["error"],
|
| 5420 |
+
"path_checked": payload.get("path_checked", KRONOS_PATH),
|
| 5421 |
+
"display": {
|
| 5422 |
+
"mode": "raw_kronos_ohlc4_line",
|
| 5423 |
+
"channels": ["ohlc4"],
|
| 5424 |
+
"output_mode": "single_future_ohlc4_line",
|
| 5425 |
+
"uncertainty_source": "raw_kronos_ohlc4_quantiles",
|
| 5426 |
+
"uses_anchor_blending": False,
|
| 5427 |
+
},
|
| 5428 |
+
"ai_runtime": payload.get("ai_runtime", {"mode": "local_only", "model": "offline"}),
|
| 5429 |
+
}
|
| 5430 |
+
return make_json_compatible(response)
|
| 5431 |
+
|
| 5432 |
+
analysis_bundle = payload["_analysis_bundle"]
|
| 5433 |
+
analysis = await asyncio.to_thread(
|
| 5434 |
+
_build_trade_analysis,
|
| 5435 |
+
symbol=payload["symbol"],
|
| 5436 |
+
interval=payload["interval"],
|
| 5437 |
+
data=payload["_data_list"],
|
| 5438 |
+
indicators=payload["indicators_snapshot"],
|
| 5439 |
+
forecast_rows=payload["forecast_rows"],
|
| 5440 |
+
confidence=float(analysis_bundle.get("confidence", 50.0)),
|
| 5441 |
+
source=payload["source"],
|
| 5442 |
+
blended=analysis_bundle,
|
| 5443 |
+
)
|
| 5444 |
+
|
| 5445 |
+
response = {
|
| 5446 |
+
"symbol": payload["symbol"],
|
| 5447 |
+
"interval": payload["interval"],
|
| 5448 |
+
"source": payload["source"],
|
| 5449 |
+
"horizon": payload["horizon"],
|
| 5450 |
+
"last_close": payload["last_close"],
|
| 5451 |
+
"forecast": payload["forecast_rows"],
|
| 5452 |
+
"from_persistent_cache": payload.get("from_persistent_cache", False),
|
| 5453 |
+
"model": payload["model"],
|
| 5454 |
+
"display": {
|
| 5455 |
+
"mode": "raw_kronos_ohlc4_line",
|
| 5456 |
+
"channels": ["ohlc4"],
|
| 5457 |
+
"output_mode": "single_future_ohlc4_line",
|
| 5458 |
+
"uncertainty_source": "raw_kronos_ohlc4_quantiles",
|
| 5459 |
+
"uses_anchor_blending": False,
|
| 5460 |
+
},
|
| 5461 |
+
"ensemble": {
|
| 5462 |
+
"mode": "raw_kronos_ohlc4",
|
| 5463 |
+
"model_weight": analysis_bundle["model_weight"],
|
| 5464 |
+
"anchor_weight": analysis_bundle["anchor_weight"],
|
| 5465 |
+
"trend_agreement": analysis_bundle["agreement"],
|
| 5466 |
+
"confidence": analysis_bundle["confidence"],
|
| 5467 |
+
"model_bias_pct": analysis_bundle["model_bias_pct"],
|
| 5468 |
+
"alignment_scale": analysis_bundle["scale"],
|
| 5469 |
+
"used_for_display": True,
|
| 5470 |
+
},
|
| 5471 |
+
"model_diagnostics": {
|
| 5472 |
+
"raw_last_p10": round(float(payload["_model_output"]["p10"][-1]), 6),
|
| 5473 |
+
"raw_last_p50": round(float(payload["_model_output"]["p50"][-1]), 6),
|
| 5474 |
+
"raw_last_p90": round(float(payload["_model_output"]["p90"][-1]), 6),
|
| 5475 |
+
"display_last_p50": round(float(analysis_bundle["p50"][-1]), 6),
|
| 5476 |
+
},
|
| 5477 |
+
"indicators_snapshot": payload["indicators_snapshot"],
|
| 5478 |
+
"analysis": analysis,
|
| 5479 |
+
"generated_at": int(time.time()),
|
| 5480 |
+
"cache": {
|
| 5481 |
+
"origin": payload["_cache_origin"],
|
| 5482 |
+
"refresh_requested": False,
|
| 5483 |
+
},
|
| 5484 |
+
"ai_runtime": payload["ai_runtime"],
|
| 5485 |
+
}
|
| 5486 |
+
|
| 5487 |
+
return make_json_compatible(response)
|
| 5488 |
+
|
| 5489 |
+
|
| 5490 |
# ββ Forecast ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5491 |
@app.get("/api/forecast/{symbol}")
|
| 5492 |
async def get_forecast(
|
|
|
|
| 5501 |
if interval not in SUPPORTED_INTERVALS:
|
| 5502 |
raise HTTPException(400, f"Unsupported interval: {interval}")
|
| 5503 |
|
| 5504 |
+
cache_key = _forecast_cache_key(symbol, interval, horizon)
|
|
|
|
| 5505 |
cache_origin = "live"
|
| 5506 |
|
| 5507 |
if not refresh:
|
| 5508 |
+
cached = _load_cached_forecast_response(cache_key, interval)
|
| 5509 |
if cached is not None:
|
|
|
|
|
|
|
| 5510 |
return cached
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5511 |
else:
|
| 5512 |
logger.info("[forecast] Refresh requested for %s %s. Bypassing caches.", symbol, interval)
|
| 5513 |
cache_origin = "live_refresh"
|
|
|
|
| 5518 |
return await inflight_task
|
| 5519 |
|
| 5520 |
async def _build_forecast_response() -> Dict[str, Any]:
|
| 5521 |
+
payload = await _prepare_forecast_response_payload(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5522 |
symbol=symbol,
|
| 5523 |
interval=interval,
|
| 5524 |
+
horizon=horizon,
|
| 5525 |
+
refresh=refresh,
|
| 5526 |
+
cache_origin=cache_origin,
|
|
|
|
|
|
|
|
|
|
| 5527 |
)
|
| 5528 |
+
response = await _finalize_forecast_response_payload(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5529 |
forecast_cache.set(cache_key, response, ttl_seconds=forecast_ttl(interval))
|
| 5530 |
persistent_cache.set(cache_key, response, ttl=forecast_ttl(interval) * 4)
|
| 5531 |
return response
|
|
|
|
| 5658 |
return ai_rule_registry.snapshot()
|
| 5659 |
|
| 5660 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5661 |
@app.get("/api/metrics")
|
| 5662 |
async def get_metrics(request: Request):
|
| 5663 |
"""Export Prometheus-ready metrics (latencies, cache hits, CB states)."""
|
|
|
|
| 5759 |
INDEX_PATH = os.path.join(FRONTEND_PATH, "index.html")
|
| 5760 |
AIBG_PATH = os.path.join(FRONTEND_PATH, "AIBG.png")
|
| 5761 |
FAVICON_PATH = os.path.join(FRONTEND_PATH, "favicon.svg")
|
| 5762 |
+
WORKSPACE_JS_PATH = os.path.join(FRONTEND_PATH, "workspace.js")
|
| 5763 |
+
WORKSPACE_CSS_PATH = os.path.join(FRONTEND_PATH, "workspace.css")
|
| 5764 |
+
|
| 5765 |
+
def _frontend_asset_headers() -> Dict[str, str]:
|
| 5766 |
+
return {
|
| 5767 |
+
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
| 5768 |
+
"Pragma": "no-cache",
|
| 5769 |
+
"Expires": "0",
|
| 5770 |
+
}
|
| 5771 |
+
|
| 5772 |
+
def _frontend_asset_version() -> str:
|
| 5773 |
+
asset_paths = [INDEX_PATH, WORKSPACE_JS_PATH, WORKSPACE_CSS_PATH]
|
| 5774 |
+
version_parts: List[str] = [APP_VERSION, CACHE_VERSION]
|
| 5775 |
+
for asset_path in asset_paths:
|
| 5776 |
+
if os.path.exists(asset_path):
|
| 5777 |
+
version_parts.append(str(int(os.path.getmtime(asset_path))))
|
| 5778 |
+
return "-".join(version_parts)
|
| 5779 |
|
| 5780 |
@app.get("/", include_in_schema=False)
|
| 5781 |
@app.get("/index.html", include_in_schema=False)
|
|
|
|
| 5784 |
raise HTTPException(status_code=404, detail="Frontend index not found")
|
| 5785 |
|
| 5786 |
html = Path(INDEX_PATH).read_text(encoding="utf-8")
|
| 5787 |
+
html = html.replace("__FRONTEND_ASSET_VERSION__", _frontend_asset_version())
|
| 5788 |
+
headers = _frontend_asset_headers()
|
|
|
|
|
|
|
|
|
|
| 5789 |
return HTMLResponse(content=html, headers=headers)
|
| 5790 |
|
| 5791 |
+
@app.get("/workspace.js", include_in_schema=False)
|
| 5792 |
+
async def serve_workspace_js() -> FileResponse:
|
| 5793 |
+
if not os.path.exists(WORKSPACE_JS_PATH):
|
| 5794 |
+
raise HTTPException(status_code=404, detail="Workspace JS asset not found")
|
| 5795 |
+
|
| 5796 |
+
return FileResponse(
|
| 5797 |
+
WORKSPACE_JS_PATH,
|
| 5798 |
+
media_type="application/javascript",
|
| 5799 |
+
headers=_frontend_asset_headers(),
|
| 5800 |
+
)
|
| 5801 |
+
|
| 5802 |
+
@app.get("/workspace.css", include_in_schema=False)
|
| 5803 |
+
async def serve_workspace_css() -> FileResponse:
|
| 5804 |
+
if not os.path.exists(WORKSPACE_CSS_PATH):
|
| 5805 |
+
raise HTTPException(status_code=404, detail="Workspace CSS asset not found")
|
| 5806 |
+
|
| 5807 |
+
return FileResponse(
|
| 5808 |
+
WORKSPACE_CSS_PATH,
|
| 5809 |
+
media_type="text/css",
|
| 5810 |
+
headers=_frontend_asset_headers(),
|
| 5811 |
+
)
|
| 5812 |
+
|
| 5813 |
@app.get("/AIBG.png", include_in_schema=False)
|
| 5814 |
async def serve_aibg() -> FileResponse:
|
| 5815 |
if not os.path.exists(AIBG_PATH):
|
|
|
|
| 5818 |
return FileResponse(
|
| 5819 |
AIBG_PATH,
|
| 5820 |
media_type="image/png",
|
| 5821 |
+
headers=_frontend_asset_headers(),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5822 |
)
|
| 5823 |
|
| 5824 |
@app.get("/favicon.svg", include_in_schema=False)
|
|
|
|
| 5830 |
return FileResponse(
|
| 5831 |
FAVICON_PATH,
|
| 5832 |
media_type="image/svg+xml",
|
| 5833 |
+
headers=_frontend_asset_headers(),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5834 |
)
|
| 5835 |
|
| 5836 |
app.mount("/", StaticFiles(directory=FRONTEND_PATH, html=True), name="frontend")
|
backend/test_api_regressions.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import os
|
| 4 |
import tempfile
|
| 5 |
import unittest
|
| 6 |
from unittest.mock import patch
|
| 7 |
|
| 8 |
from fastapi.testclient import TestClient
|
|
|
|
| 9 |
|
| 10 |
import backend.main as main
|
| 11 |
|
|
@@ -75,6 +77,48 @@ class ApiRegressionTests(unittest.TestCase):
|
|
| 75 |
self.assertIn("X-Request-ID", response.headers)
|
| 76 |
self.assertIn("X-Response-Time-Ms", response.headers)
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def test_clear_all_cache_rejects_unknown_target(self) -> None:
|
| 79 |
with patch.object(main, "ADMIN_TOKEN", "test-admin-token"):
|
| 80 |
response = self.client.delete(
|
|
@@ -197,6 +241,348 @@ class ApiRegressionTests(unittest.TestCase):
|
|
| 197 |
self.assertEqual(summary["components"]["ai_weight"], 0.4)
|
| 198 |
self.assertEqual(summary["components"]["technical_weight"], 0.6)
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
def test_ttl_cache_returns_defensive_copy(self) -> None:
|
| 201 |
cache = main.TTLCache()
|
| 202 |
payload = {"forecast": [{"price": 100.0}], "meta": {"source": "memory"}}
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
import os
|
| 5 |
import tempfile
|
| 6 |
import unittest
|
| 7 |
from unittest.mock import patch
|
| 8 |
|
| 9 |
from fastapi.testclient import TestClient
|
| 10 |
+
from fastapi import HTTPException
|
| 11 |
|
| 12 |
import backend.main as main
|
| 13 |
|
|
|
|
| 77 |
self.assertIn("X-Request-ID", response.headers)
|
| 78 |
self.assertIn("X-Response-Time-Ms", response.headers)
|
| 79 |
|
| 80 |
+
def test_real_strength_catalog_groups_dxy_usdx_and_strength_indexes(self) -> None:
|
| 81 |
+
response = self.client.get("/api/symbols")
|
| 82 |
+
self.assertEqual(response.status_code, 200)
|
| 83 |
+
body = response.json()
|
| 84 |
+
self.assertIn("Real Strength", body["categories"])
|
| 85 |
+
symbols = {entry["symbol"]: entry for entry in body["symbols"]}
|
| 86 |
+
for symbol in ["DXY", "USDX", "EURX", "GBPX", "CHFX", "JPYX", "CADX", "AUDX", "NZDX"]:
|
| 87 |
+
self.assertIn(symbol, symbols)
|
| 88 |
+
self.assertEqual(symbols[symbol]["category"], "Real Strength")
|
| 89 |
+
for symbol in ["USDX", "EURX", "GBPX", "CHFX", "JPYX", "CADX", "AUDX", "NZDX"]:
|
| 90 |
+
self.assertEqual(symbols[symbol]["sources"], ["synthetic"])
|
| 91 |
+
self.assertEqual(symbols["DXY"]["sources"], ["twelvedata", "yfinance"])
|
| 92 |
+
|
| 93 |
+
def test_forex_source_priority_excludes_binance(self) -> None:
|
| 94 |
+
self.assertEqual(main._get_source_priority("EURUSD"), ["twelvedata", "yfinance"])
|
| 95 |
+
self.assertNotIn("binance", main._get_source_priority("EURUSD"))
|
| 96 |
+
self.assertEqual(main._get_source_priority("DXY"), ["yfinance", "twelvedata"])
|
| 97 |
+
|
| 98 |
+
def test_historical_fetch_falls_back_after_provider_http_error(self) -> None:
|
| 99 |
+
sample_rows = [
|
| 100 |
+
{"time": i, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0}
|
| 101 |
+
for i in range(1, 41)
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
async def fake_twelvedata(symbol: str, interval: str, limit: int) -> list[dict[str, float]]:
|
| 105 |
+
raise HTTPException(status_code=429, detail="rate limit")
|
| 106 |
+
|
| 107 |
+
async def fake_yfinance(symbol: str, interval: str, limit: int) -> list[dict[str, float]]:
|
| 108 |
+
return sample_rows[-limit:]
|
| 109 |
+
|
| 110 |
+
with patch.object(main, "fetch_twelvedata", side_effect=fake_twelvedata), patch.object(
|
| 111 |
+
main,
|
| 112 |
+
"fetch_yfinance",
|
| 113 |
+
side_effect=fake_yfinance,
|
| 114 |
+
):
|
| 115 |
+
rows, source = asyncio.run(
|
| 116 |
+
main._run_historical_fetch("EURUSD", "1d", 40, "hist_eurusd_1d_test")
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
self.assertEqual(source, "yfinance")
|
| 120 |
+
self.assertEqual(len(rows), 40)
|
| 121 |
+
|
| 122 |
def test_clear_all_cache_rejects_unknown_target(self) -> None:
|
| 123 |
with patch.object(main, "ADMIN_TOKEN", "test-admin-token"):
|
| 124 |
response = self.client.delete(
|
|
|
|
| 241 |
self.assertEqual(summary["components"]["ai_weight"], 0.4)
|
| 242 |
self.assertEqual(summary["components"]["technical_weight"], 0.6)
|
| 243 |
|
| 244 |
+
def test_synthetic_component_candles_support_product_and_ratio(self) -> None:
|
| 245 |
+
product = main._combine_component_candles(
|
| 246 |
+
main.SyntheticComponentSpec(name="EURCAD", mode="product", left_symbol="EURUSD", right_symbol="USDCAD"),
|
| 247 |
+
{"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15},
|
| 248 |
+
{"open": 1.3, "high": 1.4, "low": 1.2, "close": 1.35},
|
| 249 |
+
)
|
| 250 |
+
ratio = main._combine_component_candles(
|
| 251 |
+
main.SyntheticComponentSpec(name="EURAUD", mode="ratio", left_symbol="EURUSD", right_symbol="AUDUSD"),
|
| 252 |
+
{"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15},
|
| 253 |
+
{"open": 0.7, "high": 0.8, "low": 0.6, "close": 0.75},
|
| 254 |
+
)
|
| 255 |
+
inverse = main._combine_component_candles(
|
| 256 |
+
main.SyntheticComponentSpec(name="EURGBP", mode="inverse", left_symbol="EURGBP"),
|
| 257 |
+
{"open": 0.85, "high": 0.86, "low": 0.84, "close": 0.855},
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
self.assertAlmostEqual(product["open"], 1.43, places=6)
|
| 261 |
+
self.assertAlmostEqual(product["high"], 1.68, places=6)
|
| 262 |
+
self.assertAlmostEqual(product["low"], 1.2, places=6)
|
| 263 |
+
self.assertAlmostEqual(ratio["open"], 1.5714285714, places=6)
|
| 264 |
+
self.assertAlmostEqual(ratio["high"], 2.0, places=6)
|
| 265 |
+
self.assertAlmostEqual(ratio["low"], 1.25, places=6)
|
| 266 |
+
self.assertAlmostEqual(inverse["open"], 1 / 0.85, places=6)
|
| 267 |
+
self.assertAlmostEqual(inverse["high"], 1 / 0.84, places=6)
|
| 268 |
+
self.assertAlmostEqual(inverse["low"], 1 / 0.86, places=6)
|
| 269 |
+
|
| 270 |
+
def test_synthetic_component_candles_use_body_only_extrema_for_intraday_product_and_ratio(self) -> None:
|
| 271 |
+
direct = main._combine_component_candles(
|
| 272 |
+
main.SyntheticComponentSpec(name="EURUSD", mode="direct", left_symbol="EURUSD"),
|
| 273 |
+
{"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15},
|
| 274 |
+
use_body_only_extrema=True,
|
| 275 |
+
)
|
| 276 |
+
inverse = main._combine_component_candles(
|
| 277 |
+
main.SyntheticComponentSpec(name="EURGBP", mode="inverse", left_symbol="EURGBP"),
|
| 278 |
+
{"open": 0.85, "high": 0.86, "low": 0.84, "close": 0.855},
|
| 279 |
+
use_body_only_extrema=True,
|
| 280 |
+
)
|
| 281 |
+
product = main._combine_component_candles(
|
| 282 |
+
main.SyntheticComponentSpec(name="EURCAD", mode="product", left_symbol="EURUSD", right_symbol="USDCAD"),
|
| 283 |
+
{"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15},
|
| 284 |
+
{"open": 1.3, "high": 1.4, "low": 1.2, "close": 1.35},
|
| 285 |
+
use_body_only_extrema=True,
|
| 286 |
+
)
|
| 287 |
+
ratio = main._combine_component_candles(
|
| 288 |
+
main.SyntheticComponentSpec(name="EURAUD", mode="ratio", left_symbol="EURUSD", right_symbol="AUDUSD"),
|
| 289 |
+
{"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.15},
|
| 290 |
+
{"open": 0.7, "high": 0.8, "low": 0.6, "close": 0.75},
|
| 291 |
+
use_body_only_extrema=True,
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
self.assertAlmostEqual(direct["open"], 1.1, places=6)
|
| 295 |
+
self.assertAlmostEqual(direct["close"], 1.15, places=6)
|
| 296 |
+
self.assertAlmostEqual(direct["high"], 1.15, places=6)
|
| 297 |
+
self.assertAlmostEqual(direct["low"], 1.1, places=6)
|
| 298 |
+
self.assertAlmostEqual(inverse["open"], 1 / 0.85, places=6)
|
| 299 |
+
self.assertAlmostEqual(inverse["close"], 1 / 0.855, places=6)
|
| 300 |
+
self.assertAlmostEqual(inverse["high"], 1 / 0.85, places=6)
|
| 301 |
+
self.assertAlmostEqual(inverse["low"], 1 / 0.855, places=6)
|
| 302 |
+
self.assertAlmostEqual(product["open"], 1.43, places=6)
|
| 303 |
+
self.assertAlmostEqual(product["close"], 1.5525, places=6)
|
| 304 |
+
self.assertAlmostEqual(product["high"], 1.5525, places=6)
|
| 305 |
+
self.assertAlmostEqual(product["low"], 1.43, places=6)
|
| 306 |
+
self.assertAlmostEqual(ratio["open"], 1.5714285714, places=6)
|
| 307 |
+
self.assertAlmostEqual(ratio["close"], 1.5333333333, places=6)
|
| 308 |
+
self.assertAlmostEqual(ratio["high"], 1.5714285714, places=6)
|
| 309 |
+
self.assertAlmostEqual(ratio["low"], 1.5333333333, places=6)
|
| 310 |
+
|
| 311 |
+
def test_synthetic_eurx_history_builds_from_component_series(self) -> None:
|
| 312 |
+
base_rows = {
|
| 313 |
+
"EURUSD": [
|
| 314 |
+
{"time": 1, "open": 1.10, "high": 1.11, "low": 1.09, "close": 1.105, "volume": 100.0},
|
| 315 |
+
{"time": 2, "open": 1.11, "high": 1.12, "low": 1.10, "close": 1.115, "volume": 100.0},
|
| 316 |
+
{"time": 3, "open": 1.12, "high": 1.13, "low": 1.11, "close": 1.125, "volume": 100.0},
|
| 317 |
+
],
|
| 318 |
+
"EURGBP": [
|
| 319 |
+
{"time": 1, "open": 0.85, "high": 0.86, "low": 0.84, "close": 0.855, "volume": 100.0},
|
| 320 |
+
{"time": 2, "open": 0.855, "high": 0.865, "low": 0.845, "close": 0.86, "volume": 100.0},
|
| 321 |
+
{"time": 3, "open": 0.86, "high": 0.87, "low": 0.85, "close": 0.865, "volume": 100.0},
|
| 322 |
+
],
|
| 323 |
+
"USDCHF": [
|
| 324 |
+
{"time": 1, "open": 0.90, "high": 0.91, "low": 0.89, "close": 0.905, "volume": 100.0},
|
| 325 |
+
{"time": 2, "open": 0.905, "high": 0.915, "low": 0.895, "close": 0.91, "volume": 100.0},
|
| 326 |
+
{"time": 3, "open": 0.91, "high": 0.92, "low": 0.90, "close": 0.915, "volume": 100.0},
|
| 327 |
+
],
|
| 328 |
+
"EURJPY": [
|
| 329 |
+
{"time": 1, "open": 160.0, "high": 161.0, "low": 159.0, "close": 160.5, "volume": 100.0},
|
| 330 |
+
{"time": 2, "open": 160.5, "high": 161.5, "low": 159.5, "close": 161.0, "volume": 100.0},
|
| 331 |
+
{"time": 3, "open": 161.0, "high": 162.0, "low": 160.0, "close": 161.5, "volume": 100.0},
|
| 332 |
+
],
|
| 333 |
+
"USDCAD": [
|
| 334 |
+
{"time": 1, "open": 1.35, "high": 1.36, "low": 1.34, "close": 1.355, "volume": 100.0},
|
| 335 |
+
{"time": 2, "open": 1.355, "high": 1.365, "low": 1.345, "close": 1.36, "volume": 100.0},
|
| 336 |
+
{"time": 3, "open": 1.36, "high": 1.37, "low": 1.35, "close": 1.365, "volume": 100.0},
|
| 337 |
+
],
|
| 338 |
+
"AUDUSD": [
|
| 339 |
+
{"time": 1, "open": 0.66, "high": 0.67, "low": 0.65, "close": 0.665, "volume": 100.0},
|
| 340 |
+
{"time": 2, "open": 0.665, "high": 0.675, "low": 0.655, "close": 0.67, "volume": 100.0},
|
| 341 |
+
{"time": 3, "open": 0.67, "high": 0.68, "low": 0.66, "close": 0.675, "volume": 100.0},
|
| 342 |
+
],
|
| 343 |
+
"NZDUSD": [
|
| 344 |
+
{"time": 1, "open": 0.61, "high": 0.62, "low": 0.60, "close": 0.615, "volume": 100.0},
|
| 345 |
+
{"time": 2, "open": 0.615, "high": 0.625, "low": 0.605, "close": 0.62, "volume": 100.0},
|
| 346 |
+
{"time": 3, "open": 0.62, "high": 0.63, "low": 0.61, "close": 0.625, "volume": 100.0},
|
| 347 |
+
],
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
async def fake_fetch_from_source(
|
| 351 |
+
source: str,
|
| 352 |
+
symbol: str,
|
| 353 |
+
interval: str,
|
| 354 |
+
limit: int,
|
| 355 |
+
) -> tuple[list[dict[str, float]], str]:
|
| 356 |
+
self.assertIn(source, {"twelvedata", "finnhub", "yfinance"})
|
| 357 |
+
return base_rows[symbol][-limit:]
|
| 358 |
+
|
| 359 |
+
with patch.object(main, "_fetch_historical_from_source", side_effect=fake_fetch_from_source):
|
| 360 |
+
rows, source = asyncio.run(
|
| 361 |
+
main._build_synthetic_symbol_history("EURX", "1h", 3, "test-cache-key")
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
self.assertEqual(len(rows), 3)
|
| 365 |
+
self.assertEqual(rows[-1]["time"], 3)
|
| 366 |
+
self.assertEqual(rows[-1]["volume"], 0.0)
|
| 367 |
+
self.assertEqual(source, "synthetic:twelvedata")
|
| 368 |
+
self.assertGreater(rows[-1]["close"], 0.0)
|
| 369 |
+
|
| 370 |
+
def test_synthetic_ticker_uses_requested_interval_instead_of_forcing_5m(self) -> None:
|
| 371 |
+
calls: list[tuple[str, str, int, int]] = []
|
| 372 |
+
|
| 373 |
+
async def fake_fetch_historical(
|
| 374 |
+
symbol: str,
|
| 375 |
+
interval: str,
|
| 376 |
+
limit: int,
|
| 377 |
+
min_context: int = 0,
|
| 378 |
+
**_: object,
|
| 379 |
+
) -> tuple[list[dict[str, float]], str]:
|
| 380 |
+
calls.append((symbol, interval, limit, min_context))
|
| 381 |
+
rows = [
|
| 382 |
+
{"time": 1, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0},
|
| 383 |
+
{"time": 2, "open": 1.0, "high": 1.2, "low": 0.95, "close": 1.1, "volume": 0.0},
|
| 384 |
+
]
|
| 385 |
+
return rows, "synthetic:test"
|
| 386 |
+
|
| 387 |
+
with patch.object(main, "fetch_historical", side_effect=fake_fetch_historical):
|
| 388 |
+
ticker = asyncio.run(main.fetch_ticker("CHFX", interval="1d"))
|
| 389 |
+
|
| 390 |
+
self.assertEqual(ticker["symbol"], "CHFX")
|
| 391 |
+
self.assertEqual(calls, [("CHFX", "1d", 2, 2)])
|
| 392 |
+
|
| 393 |
+
def test_synthetic_history_uses_single_source_and_requested_interval(self) -> None:
|
| 394 |
+
calls: list[tuple[str, str, str, int]] = []
|
| 395 |
+
|
| 396 |
+
async def fake_fetch_from_source(
|
| 397 |
+
source: str,
|
| 398 |
+
symbol: str,
|
| 399 |
+
interval: str,
|
| 400 |
+
limit: int,
|
| 401 |
+
) -> list[dict[str, float]]:
|
| 402 |
+
calls.append((source, symbol, interval, limit))
|
| 403 |
+
return [
|
| 404 |
+
{"time": 1, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0},
|
| 405 |
+
{"time": 2, "open": 1.0, "high": 1.2, "low": 0.95, "close": 1.1, "volume": 0.0},
|
| 406 |
+
{"time": 3, "open": 1.1, "high": 1.25, "low": 1.0, "close": 1.15, "volume": 0.0},
|
| 407 |
+
]
|
| 408 |
+
|
| 409 |
+
with patch.object(main, "_fetch_historical_from_source", side_effect=fake_fetch_from_source):
|
| 410 |
+
rows, source = asyncio.run(
|
| 411 |
+
main._build_synthetic_symbol_history("CHFX", "4h", 3, "synthetic-chfx-4h")
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
self.assertEqual(len(rows), 3)
|
| 415 |
+
self.assertEqual(source, "synthetic:twelvedata")
|
| 416 |
+
self.assertTrue(calls)
|
| 417 |
+
self.assertEqual({item[0] for item in calls}, {"twelvedata"})
|
| 418 |
+
self.assertEqual({item[2] for item in calls}, {"4h"})
|
| 419 |
+
|
| 420 |
+
def test_kronos_feature_prep_collapses_prices_to_ohlc4_and_zeroes_volume_channels(self) -> None:
|
| 421 |
+
df = main.pd.DataFrame(
|
| 422 |
+
[
|
| 423 |
+
{"open": 1.0, "high": 1.1, "low": 0.9, "close": 1.05, "volume": 0.0, "amount": 0.0},
|
| 424 |
+
{"open": 1.05, "high": 1.15, "low": 0.95, "close": 1.1, "volume": 0.0, "amount": 0.0},
|
| 425 |
+
]
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
prepared = main.KronosForecaster._prepare_feature_frame(df)
|
| 429 |
+
expected_ohlc4 = df[["open", "high", "low", "close"]].mean(axis=1).astype(main.np.float32)
|
| 430 |
+
|
| 431 |
+
self.assertEqual(list(prepared.columns), ["ohlc4"])
|
| 432 |
+
self.assertTrue(main.np.allclose(prepared["ohlc4"].values, expected_ohlc4.values))
|
| 433 |
+
|
| 434 |
+
def test_kronos_feature_prep_ignores_upstream_volume_and_amount_noise(self) -> None:
|
| 435 |
+
df = main.pd.DataFrame(
|
| 436 |
+
[
|
| 437 |
+
{"open": 10.0, "high": 11.0, "low": 9.0, "close": 10.5, "volume": 100.0, "amount": 1050.0},
|
| 438 |
+
{"open": 11.0, "high": 12.0, "low": 10.0, "close": 11.5, "volume": 200.0, "amount": 2300.0},
|
| 439 |
+
]
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
prepared = main.KronosForecaster._prepare_feature_frame(df)
|
| 443 |
+
expected_ohlc4 = df[["open", "high", "low", "close"]].mean(axis=1).astype(main.np.float32)
|
| 444 |
+
|
| 445 |
+
self.assertEqual(list(prepared.columns), ["ohlc4"])
|
| 446 |
+
self.assertTrue(main.np.allclose(prepared["ohlc4"].values, expected_ohlc4.values))
|
| 447 |
+
|
| 448 |
+
def test_kronos_normalization_uses_std_plus_epsilon_contract(self) -> None:
|
| 449 |
+
x = main.np.array(
|
| 450 |
+
[
|
| 451 |
+
[1.0, 1.1, 0.9, 1.0, 0.0, 0.0],
|
| 452 |
+
[1.1, 1.2, 1.0, 1.1, 0.0, 0.0],
|
| 453 |
+
[1.2, 1.3, 1.1, 1.2, 0.0, 0.0],
|
| 454 |
+
],
|
| 455 |
+
dtype=main.np.float32,
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
x_norm, x_mean, x_scale = main.KronosForecaster._normalize_feature_matrix(x, clip=5.0)
|
| 459 |
+
|
| 460 |
+
expected_scale = main.np.std(x, axis=0).astype(main.np.float32) + 1e-5
|
| 461 |
+
expected_norm = main.np.clip((x - x_mean) / expected_scale, -5.0, 5.0)
|
| 462 |
+
|
| 463 |
+
self.assertTrue(main.np.allclose(x_scale, expected_scale))
|
| 464 |
+
self.assertTrue(main.np.allclose(x_norm, expected_norm))
|
| 465 |
+
|
| 466 |
+
def test_forecast_payload_schema_guard_rejects_legacy_blended_payload(self) -> None:
|
| 467 |
+
legacy_payload = {
|
| 468 |
+
"forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}],
|
| 469 |
+
"forecast_candles": [],
|
| 470 |
+
"display": {"mode": "raw_kronos_ohlc_p50"},
|
| 471 |
+
"ensemble": {"mode": "kronos_plus_anchor", "confidence": 55.0},
|
| 472 |
+
"model": {
|
| 473 |
+
"input_semantics": {
|
| 474 |
+
"feature_channels": ["open", "high", "low", "close", "volume", "amount"],
|
| 475 |
+
"price_mode": "ohlc4_replicated_across_ohlc",
|
| 476 |
+
"base_signal": "ohlc4",
|
| 477 |
+
"volume_mode": "forced_zero",
|
| 478 |
+
"amount_mode": "forced_zero",
|
| 479 |
+
"active_forecast_channels": ["ohlc4"],
|
| 480 |
+
}
|
| 481 |
+
},
|
| 482 |
+
}
|
| 483 |
+
self.assertFalse(main._forecast_payload_is_current(legacy_payload))
|
| 484 |
+
|
| 485 |
+
def test_forecast_payload_schema_guard_accepts_current_ohlc4_input_mode(self) -> None:
|
| 486 |
+
current_payload = {
|
| 487 |
+
"forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}],
|
| 488 |
+
"display": {
|
| 489 |
+
"mode": "raw_kronos_ohlc4_line",
|
| 490 |
+
"channels": ["ohlc4"],
|
| 491 |
+
"output_mode": "single_future_ohlc4_line",
|
| 492 |
+
},
|
| 493 |
+
"model": {
|
| 494 |
+
"input_semantics": {
|
| 495 |
+
"feature_channels": ["ohlc4"],
|
| 496 |
+
"price_mode": "ohlc4_single_channel",
|
| 497 |
+
"base_signal": "ohlc4",
|
| 498 |
+
"volume_mode": "omitted",
|
| 499 |
+
"amount_mode": "omitted",
|
| 500 |
+
"active_forecast_channels": ["ohlc4"],
|
| 501 |
+
"adapter_mode": "tokenizer_6ch_to_1ch_ohlc4",
|
| 502 |
+
},
|
| 503 |
+
"output_semantics": {
|
| 504 |
+
"forecast_channel": "ohlc4",
|
| 505 |
+
"forecast_mode": "single_future_ohlc4_line",
|
| 506 |
+
"candle_projection": "omitted",
|
| 507 |
+
}
|
| 508 |
+
},
|
| 509 |
+
}
|
| 510 |
+
self.assertTrue(main._forecast_payload_is_current(current_payload))
|
| 511 |
+
|
| 512 |
+
def test_forecast_payload_schema_guard_rejects_legacy_forecast_candles_field(self) -> None:
|
| 513 |
+
stale_payload = {
|
| 514 |
+
"forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}],
|
| 515 |
+
"forecast_candles": [],
|
| 516 |
+
"display": {
|
| 517 |
+
"mode": "raw_kronos_ohlc4_line",
|
| 518 |
+
"channels": ["ohlc4"],
|
| 519 |
+
"output_mode": "single_future_ohlc4_line",
|
| 520 |
+
},
|
| 521 |
+
"model": {
|
| 522 |
+
"input_semantics": {
|
| 523 |
+
"feature_channels": ["ohlc4"],
|
| 524 |
+
"price_mode": "ohlc4_single_channel",
|
| 525 |
+
"base_signal": "ohlc4",
|
| 526 |
+
"volume_mode": "omitted",
|
| 527 |
+
"amount_mode": "omitted",
|
| 528 |
+
"active_forecast_channels": ["ohlc4"],
|
| 529 |
+
"adapter_mode": "tokenizer_6ch_to_1ch_ohlc4",
|
| 530 |
+
},
|
| 531 |
+
"output_semantics": {
|
| 532 |
+
"forecast_channel": "ohlc4",
|
| 533 |
+
"forecast_mode": "single_future_ohlc4_line",
|
| 534 |
+
"candle_projection": "omitted",
|
| 535 |
+
},
|
| 536 |
+
},
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
self.assertFalse(main._forecast_payload_is_current(stale_payload))
|
| 540 |
+
|
| 541 |
+
def test_finalize_forecast_error_payload_omits_legacy_forecast_candles_field(self) -> None:
|
| 542 |
+
payload = {
|
| 543 |
+
"symbol": self.symbol_a,
|
| 544 |
+
"interval": "1h",
|
| 545 |
+
"forecast_rows": [],
|
| 546 |
+
"error": "offline",
|
| 547 |
+
"path_checked": "test-path",
|
| 548 |
+
"ai_runtime": {"mode": "local_only", "model": "offline"},
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
response = asyncio.run(main._finalize_forecast_response_payload(payload))
|
| 552 |
+
|
| 553 |
+
self.assertNotIn("forecast_candles", response)
|
| 554 |
+
self.assertEqual(response["display"]["output_mode"], "single_future_ohlc4_line")
|
| 555 |
+
|
| 556 |
+
def test_synthetic_component_history_reuses_source_cache(self) -> None:
|
| 557 |
+
calls: list[tuple[str, str, str, int]] = []
|
| 558 |
+
|
| 559 |
+
async def fake_fetch_from_source(
|
| 560 |
+
source: str,
|
| 561 |
+
symbol: str,
|
| 562 |
+
interval: str,
|
| 563 |
+
limit: int,
|
| 564 |
+
) -> list[dict[str, float]]:
|
| 565 |
+
calls.append((source, symbol, interval, limit))
|
| 566 |
+
return [
|
| 567 |
+
{"time": 1, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0},
|
| 568 |
+
{"time": 2, "open": 1.0, "high": 1.2, "low": 0.95, "close": 1.1, "volume": 0.0},
|
| 569 |
+
{"time": 3, "open": 1.1, "high": 1.25, "low": 1.0, "close": 1.15, "volume": 0.0},
|
| 570 |
+
]
|
| 571 |
+
|
| 572 |
+
main.source_history_cache.clear()
|
| 573 |
+
main._SOURCE_HISTORY_INFLIGHT.clear()
|
| 574 |
+
try:
|
| 575 |
+
with patch.object(main, "_fetch_historical_from_source", side_effect=fake_fetch_from_source):
|
| 576 |
+
asyncio.run(main._build_synthetic_symbol_history("EURX", "1d", 3, "synthetic-eurx-1"))
|
| 577 |
+
asyncio.run(main._build_synthetic_symbol_history("EURX", "1d", 3, "synthetic-eurx-2"))
|
| 578 |
+
finally:
|
| 579 |
+
main.source_history_cache.clear()
|
| 580 |
+
main._SOURCE_HISTORY_INFLIGHT.clear()
|
| 581 |
+
|
| 582 |
+
unique_component_symbols = {item[1] for item in calls}
|
| 583 |
+
self.assertEqual(len(calls), len(unique_component_symbols))
|
| 584 |
+
self.assertEqual(unique_component_symbols, {"AUDUSD", "EURGBP", "EURJPY", "EURUSD", "NZDUSD", "USDCAD", "USDCHF"})
|
| 585 |
+
|
| 586 |
def test_ttl_cache_returns_defensive_copy(self) -> None:
|
| 587 |
cache = main.TTLCache()
|
| 588 |
payload = {"forecast": [{"price": 100.0}], "meta": {"source": "memory"}}
|
frontend/favicon.svg
CHANGED
|
|
|
|
Git LFS Details
|
frontend/index.html
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
<html lang="vi">
|
| 3 |
|
| 4 |
<head>
|
|
@@ -21,6 +21,7 @@
|
|
| 21 |
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Barlow:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap"
|
| 22 |
rel="stylesheet">
|
| 23 |
<script src="https://unpkg.com/lightweight-charts@4.2.2/dist/lightweight-charts.standalone.production.js"></script>
|
|
|
|
| 24 |
<style>
|
| 25 |
/* ββ Chart Transition (P2) ββ */
|
| 26 |
#chart {
|
|
@@ -3297,6 +3298,31 @@
|
|
| 3297 |
</select>
|
| 3298 |
</div>
|
| 3299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3300 |
<button class="btn-theme" id="themeToggleBtn" title="Chuyα»n Δα»i giao diα»n">
|
| 3301 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
| 3302 |
stroke-linejoin="round" class="sun-icon">
|
|
@@ -3312,7 +3338,7 @@
|
|
| 3312 |
</svg>
|
| 3313 |
</button>
|
| 3314 |
|
| 3315 |
-
<button class="btn-primary" id="refreshBtn">PhΓ’n tΓch</button>
|
| 3316 |
|
| 3317 |
|
| 3318 |
<button class="btn-icon" id="fitBtn" title="Khα»p toΓ n bα» dα»― liα»u">
|
|
@@ -3359,10 +3385,28 @@
|
|
| 3359 |
<div class="chart-bg-overlay"></div>
|
| 3360 |
<div class="chart-logo-overlay">KRONOS AI</div>
|
| 3361 |
|
| 3362 |
-
<!--
|
| 3363 |
-
<div id="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3364 |
|
| 3365 |
-
<!-- Compact Gauges Overlay (v6.0) -->
|
| 3366 |
<div class="chart-gauges-container" id="chartGauges"></div>
|
| 3367 |
|
| 3368 |
<aside class="analysis-panel hidden" id="analysisPanel"></aside>
|
|
@@ -3402,14 +3446,20 @@
|
|
| 3402 |
</div>
|
| 3403 |
</div>
|
| 3404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3405 |
<!-- ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3406 |
JAVASCRIPT β all original logic preserved
|
| 3407 |
ββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 3408 |
<script>
|
| 3409 |
const API_BASE = (window.location.origin && window.location.origin !== 'null')
|
| 3410 |
? window.location.origin
|
| 3411 |
-
: '
|
| 3412 |
const HEADER_VISIBILITY_KEY = 'kronos_header_visibility';
|
|
|
|
| 3413 |
|
| 3414 |
/* ββ DOM refs ββββββββββββββββββββββββββββββββ */
|
| 3415 |
/* ββ DOM refs ββββββββββββββββββββββββββββββββ */
|
|
@@ -3621,14 +3671,21 @@
|
|
| 3621 |
});
|
| 3622 |
})();
|
| 3623 |
|
| 3624 |
-
function connectWS(symbol) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3625 |
if (ws) {
|
| 3626 |
ws.close();
|
| 3627 |
ws = null;
|
| 3628 |
}
|
| 3629 |
|
| 3630 |
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 3631 |
-
const wsUrl = `${API_BASE.replace(/^https?:\/\//, wsProtocol)}/ws/price/${symbol}`;
|
| 3632 |
|
| 3633 |
console.log(`[WS] Connecting to ${wsUrl}`);
|
| 3634 |
ws = new WebSocket(wsUrl);
|
|
@@ -3645,7 +3702,7 @@
|
|
| 3645 |
|
| 3646 |
// Update current candle if price changed
|
| 3647 |
const lastCandle = lastCandleData;
|
| 3648 |
-
if (lastCandle && data.price) {
|
| 3649 |
const update = {
|
| 3650 |
time: lastCandle.time,
|
| 3651 |
open: lastCandle.open,
|
|
@@ -3663,7 +3720,7 @@
|
|
| 3663 |
console.log('[WS] Disconnected');
|
| 3664 |
// Reconnect after 5s if still active
|
| 3665 |
setTimeout(() => {
|
| 3666 |
-
if (currentSymbol === symbol) connectWS(symbol);
|
| 3667 |
}, 5000);
|
| 3668 |
};
|
| 3669 |
}
|
|
@@ -3725,7 +3782,11 @@
|
|
| 3725 |
}
|
| 3726 |
|
| 3727 |
/* ββ Switch Logic βββββββββββββββββββββββββββββ */
|
| 3728 |
-
async function switchSymbol(symbol) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3729 |
currentSymbol = symbol;
|
| 3730 |
symbolSearch.value = symbol;
|
| 3731 |
searchResults.classList.remove('visible');
|
|
@@ -3742,6 +3803,156 @@
|
|
| 3742 |
/* ββ Chart init ββββββββββββββββββββββββββββββ */
|
| 3743 |
const chartEl = document.getElementById('chart');
|
| 3744 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3745 |
const chart = LightweightCharts.createChart(chartEl, {
|
| 3746 |
layout: {
|
| 3747 |
background: { type: 'solid', color: 'transparent' },
|
|
@@ -3764,6 +3975,7 @@
|
|
| 3764 |
secondsVisible: false,
|
| 3765 |
fixLeftEdge: false,
|
| 3766 |
fixRightEdge: false,
|
|
|
|
| 3767 |
},
|
| 3768 |
crosshair: {
|
| 3769 |
mode: LightweightCharts.CrosshairMode.Normal,
|
|
@@ -3799,6 +4011,8 @@
|
|
| 3799 |
wickDownColor: '#e05560',
|
| 3800 |
});
|
| 3801 |
|
|
|
|
|
|
|
| 3802 |
const p50Series = chart.addLineSeries({
|
| 3803 |
color: '#66d9ff',
|
| 3804 |
lineWidth: 2,
|
|
@@ -3840,8 +4054,8 @@
|
|
| 3840 |
const prev = points[i - 1];
|
| 3841 |
const curr = points[i];
|
| 3842 |
const diff = (curr?.value ?? 0) - (prev?.value ?? 0);
|
| 3843 |
-
// TΔng: xanh lΓ‘, GiαΊ£m: Δα», Δi ngang: vΓ ng
|
| 3844 |
-
const color = diff
|
| 3845 |
const segSeries = chart.addLineSeries({
|
| 3846 |
color,
|
| 3847 |
lineWidth: 2,
|
|
@@ -3865,9 +4079,9 @@
|
|
| 3865 |
});
|
| 3866 |
|
| 3867 |
/* ββ Indicator Series ββββββββββββββββββββββββ */
|
| 3868 |
-
const bbMiddleSeries = chart.addLineSeries({ color: 'rgba(255, 255, 255, 0.2)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false });
|
| 3869 |
-
const bbUpperSeries = chart.addLineSeries({ color: 'rgba(34, 211, 238, 0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false });
|
| 3870 |
-
const bbLowerSeries = chart.addLineSeries({ color: 'rgba(34, 211, 238, 0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false });
|
| 3871 |
|
| 3872 |
let rsiSeries = null;
|
| 3873 |
|
|
@@ -3905,6 +4119,22 @@
|
|
| 3905 |
}
|
| 3906 |
}
|
| 3907 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3908 |
function dimChartForRefresh() {
|
| 3909 |
// Show loading placeholder on chart
|
| 3910 |
const chartContainer = document.getElementById('chart-container');
|
|
@@ -4056,6 +4286,7 @@
|
|
| 4056 |
function syncChartPriceFormat(symbol, candles = []) {
|
| 4057 |
currentPriceFormat = resolvePriceFormat(symbol, candles);
|
| 4058 |
applyPriceFormatToSeries(candleSeries);
|
|
|
|
| 4059 |
applyPriceFormatToSeries(p50Series);
|
| 4060 |
applyPriceFormatToSeries(p10Series);
|
| 4061 |
applyPriceFormatToSeries(p90Series);
|
|
@@ -4147,11 +4378,21 @@
|
|
| 4147 |
|
| 4148 |
function renderCompactGauges(symbol, interval, payload) {
|
| 4149 |
const container = document.getElementById('chartGauges');
|
| 4150 |
-
if (!container
|
| 4151 |
-
|
| 4152 |
-
|
| 4153 |
-
|
| 4154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4155 |
return;
|
| 4156 |
}
|
| 4157 |
|
|
@@ -4419,12 +4660,14 @@
|
|
| 4419 |
}
|
| 4420 |
|
| 4421 |
function setPrimaryForecastVisibility(visible) {
|
| 4422 |
-
p50Series.applyOptions({ visible
|
| 4423 |
-
p10Series.applyOptions({ visible });
|
| 4424 |
-
p90Series.applyOptions({ visible });
|
|
|
|
| 4425 |
}
|
| 4426 |
|
| 4427 |
function clearForecastVisuals() {
|
|
|
|
| 4428 |
p50Series.setData([]);
|
| 4429 |
p10Series.setData([]);
|
| 4430 |
p90Series.setData([]);
|
|
@@ -4433,15 +4676,166 @@
|
|
| 4433 |
activeForecastContext = { symbol: null, interval: null, ready: false };
|
| 4434 |
}
|
| 4435 |
|
| 4436 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4437 |
p50Series.setData([]);
|
| 4438 |
-
p10Series.setData(
|
| 4439 |
-
p90Series.setData(
|
| 4440 |
-
|
| 4441 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4442 |
activeForecastContext = { symbol, interval, ready: true };
|
| 4443 |
}
|
| 4444 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4445 |
function scheduleAnalysisRetry(symbol, interval) {
|
| 4446 |
if (analysisRetryTimer) return;
|
| 4447 |
analysisRetryTimer = setTimeout(() => {
|
|
@@ -4573,14 +4967,28 @@
|
|
| 4573 |
const indicators = indData.indicators || {};
|
| 4574 |
const series = indicators.series || {};
|
| 4575 |
|
| 4576 |
-
if (
|
| 4577 |
-
|
| 4578 |
-
|
| 4579 |
-
if (series.bb_lower) bbLowerSeries.setData(series.bb_lower);
|
| 4580 |
-
}
|
| 4581 |
|
| 4582 |
-
|
| 4583 |
activeChartContext = { symbol, interval };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4584 |
hideLoader();
|
| 4585 |
updateStatus(
|
| 4586 |
hasLiveForecastFor(symbol, interval)
|
|
@@ -4588,7 +4996,6 @@
|
|
| 4588 |
: `${symbol} | ${interval} - Dang nap AI...`,
|
| 4589 |
'loading'
|
| 4590 |
);
|
| 4591 |
-
fetchAIAnalysis(symbol, interval);
|
| 4592 |
} catch (e) {
|
| 4593 |
if (e.name === 'AbortError') return;
|
| 4594 |
console.error('Stage 1 Fetch Error:', e);
|
|
@@ -4602,6 +5009,8 @@
|
|
| 4602 |
}
|
| 4603 |
|
| 4604 |
async function fetchAIAnalysis(symbol, interval, options = {}) {
|
|
|
|
|
|
|
| 4605 |
if (currentSymbol !== symbol || timeframeSelect.value !== interval) return null;
|
| 4606 |
|
| 4607 |
const panel = document.getElementById('analysisPanel');
|
|
@@ -4628,6 +5037,8 @@
|
|
| 4628 |
analysisFetchController.abort();
|
| 4629 |
}
|
| 4630 |
|
|
|
|
|
|
|
| 4631 |
const controller = new AbortController();
|
| 4632 |
analysisFetchController = controller;
|
| 4633 |
analysisRequestKey = requestKey;
|
|
@@ -4641,9 +5052,22 @@
|
|
| 4641 |
|
| 4642 |
if (currentSymbol !== symbol || timeframeSelect.value !== interval) return null;
|
| 4643 |
|
| 4644 |
-
const
|
| 4645 |
const hasAnalysis = Boolean(fData.analysis);
|
| 4646 |
const hadForecast = hasLiveForecastFor(symbol, interval);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4647 |
|
| 4648 |
if (!hasForecast || !lastCandleData || !lastCandleData.time) {
|
| 4649 |
if (!hadForecast) {
|
|
@@ -4664,27 +5088,16 @@
|
|
| 4664 |
return fData;
|
| 4665 |
}
|
| 4666 |
|
| 4667 |
-
|
| 4668 |
-
const anchorPoint = { time: lastCandleData.time, value: lastCandleData.close };
|
| 4669 |
-
const futurePoints = forecastPoints
|
| 4670 |
-
.filter(d => d && d.time !== undefined && d.p50 !== undefined && d.time !== lastCandleData.time)
|
| 4671 |
-
.map(d => ({ time: d.time, value: d.p50 }));
|
| 4672 |
-
const p50 = [anchorPoint, ...futurePoints];
|
| 4673 |
-
const p10 = [anchorPoint, ...forecastPoints
|
| 4674 |
-
.filter(d => d && d.time !== undefined && d.p10 !== undefined && d.time !== lastCandleData.time)
|
| 4675 |
-
.map(d => ({ time: d.time, value: d.p10 }))];
|
| 4676 |
-
const p90 = [anchorPoint, ...forecastPoints
|
| 4677 |
-
.filter(d => d && d.time !== undefined && d.p90 !== undefined && d.time !== lastCandleData.time)
|
| 4678 |
-
.map(d => ({ time: d.time, value: d.p90 }))];
|
| 4679 |
|
| 4680 |
renderAnalysisPanel(symbol, interval, fData);
|
| 4681 |
setTimeout(updateDashboardScale, 10);
|
| 4682 |
renderCompactGauges(symbol, interval, fData);
|
| 4683 |
-
commitForecastVisuals(symbol, interval, p50, p10, p90);
|
| 4684 |
|
| 4685 |
const currentPrice = lastCandleData?.close || 0;
|
| 4686 |
-
const
|
| 4687 |
-
const
|
|
|
|
| 4688 |
const pctChange = currentPrice > 0 ? ((lastForecastVal - currentPrice) / currentPrice) * 100 : 0;
|
| 4689 |
const pctLabel = (pctChange >= 0 ? '+' : '') + pctChange.toFixed(2) + '%';
|
| 4690 |
const trend = isBull ? 'TANG' : 'GIAM';
|
|
@@ -4823,8 +5236,165 @@
|
|
| 4823 |
closeExplorerBtn.onclick = closeExplorer;
|
| 4824 |
toggleMarketBtn.onclick = openExplorer;
|
| 4825 |
|
| 4826 |
-
|
| 4827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4828 |
|
| 4829 |
// "PhΓ’n tΓch" button: toggle dashboard ON/OFF without reloading chart
|
| 4830 |
refreshBtn.onclick = () => {
|
|
@@ -4853,7 +5423,37 @@
|
|
| 4853 |
}
|
| 4854 |
};
|
| 4855 |
|
| 4856 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4857 |
|
| 4858 |
document.addEventListener('keydown', (e) => {
|
| 4859 |
const target = e.target;
|
|
@@ -5000,6 +5600,570 @@
|
|
| 5000 |
applyTheme(newTheme);
|
| 5001 |
};
|
| 5002 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5003 |
/* ββ Bootstrap ββ */
|
| 5004 |
(async () => {
|
| 5005 |
// Restore theme preference (Default: light)
|
|
@@ -5013,32 +6177,397 @@
|
|
| 5013 |
// Start polling
|
| 5014 |
setInterval(refreshMarketStatus, 60000); // 1m
|
| 5015 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5016 |
|
| 5017 |
-
|
| 5018 |
-
|
|
|
|
| 5019 |
|
| 5020 |
// Setup auto-refresh (P1-10)
|
| 5021 |
let autoRefreshTimer = null;
|
| 5022 |
function scheduleAutoRefresh() {
|
| 5023 |
if (autoRefreshTimer) clearTimeout(autoRefreshTimer);
|
| 5024 |
|
| 5025 |
-
// Interval logic:
|
| 5026 |
const intv = timeframeSelect.value;
|
| 5027 |
-
let delay =
|
| 5028 |
if (intv === '1m' || intv === '5m') delay = 60000;
|
| 5029 |
else if (intv === '15m' || intv === '30m') delay = 180000;
|
| 5030 |
else if (intv === '1h' || intv === '4h') delay = 600000;
|
|
|
|
| 5031 |
|
| 5032 |
autoRefreshTimer = setTimeout(async () => {
|
| 5033 |
if (!document.hidden) {
|
| 5034 |
console.log('[AutoRefresh] Triggering...');
|
| 5035 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5036 |
}
|
| 5037 |
scheduleAutoRefresh();
|
| 5038 |
}, delay);
|
| 5039 |
}
|
| 5040 |
scheduleAutoRefresh();
|
| 5041 |
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5042 |
</script>
|
| 5043 |
</body>
|
| 5044 |
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
<html lang="vi">
|
| 3 |
|
| 4 |
<head>
|
|
|
|
| 21 |
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Barlow:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap"
|
| 22 |
rel="stylesheet">
|
| 23 |
<script src="https://unpkg.com/lightweight-charts@4.2.2/dist/lightweight-charts.standalone.production.js"></script>
|
| 24 |
+
<link rel="stylesheet" href="/workspace.css?v=__FRONTEND_ASSET_VERSION__">
|
| 25 |
<style>
|
| 26 |
/* ββ Chart Transition (P2) ββ */
|
| 27 |
#chart {
|
|
|
|
| 3298 |
</select>
|
| 3299 |
</div>
|
| 3300 |
|
| 3301 |
+
<div class="layout-menu" id="layoutSwitcher">
|
| 3302 |
+
<button class="layout-menu-button" id="layoutMenuBtn" type="button" aria-haspopup="true" aria-expanded="false" title="Chα»n bα» cα»₯c chart">
|
| 3303 |
+
<span>Pane</span>
|
| 3304 |
+
<span class="layout-menu-current" id="layoutMenuCurrent">1</span>
|
| 3305 |
+
</button>
|
| 3306 |
+
<div class="layout-menu-popup" id="layoutMenuPopup">
|
| 3307 |
+
<button class="layout-menu-option active" type="button" data-layout="1" title="1 chart">
|
| 3308 |
+
<strong>1 Pane</strong>
|
| 3309 |
+
<span>TαΊp trung</span>
|
| 3310 |
+
</button>
|
| 3311 |
+
<button class="layout-menu-option" type="button" data-layout="2" title="2 charts">
|
| 3312 |
+
<strong>2 Pane</strong>
|
| 3313 |
+
<span>So sΓ‘nh ΔΓ΄i</span>
|
| 3314 |
+
</button>
|
| 3315 |
+
<button class="layout-menu-option" type="button" data-layout="4" title="4 charts">
|
| 3316 |
+
<strong>4 Pane</strong>
|
| 3317 |
+
<span>Quan sΓ‘t nhΓ³m</span>
|
| 3318 |
+
</button>
|
| 3319 |
+
<button class="layout-menu-option" type="button" data-layout="8" title="8 charts">
|
| 3320 |
+
<strong>8 Pane</strong>
|
| 3321 |
+
<span>ToΓ n cαΊ£nh</span>
|
| 3322 |
+
</button>
|
| 3323 |
+
</div>
|
| 3324 |
+
</div>
|
| 3325 |
+
|
| 3326 |
<button class="btn-theme" id="themeToggleBtn" title="Chuyα»n Δα»i giao diα»n">
|
| 3327 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
| 3328 |
stroke-linejoin="round" class="sun-icon">
|
|
|
|
| 3338 |
</svg>
|
| 3339 |
</button>
|
| 3340 |
|
| 3341 |
+
<button class="btn-primary" id="refreshBtn" hidden aria-hidden="true" tabindex="-1">PhΓ’n tΓch</button>
|
| 3342 |
|
| 3343 |
|
| 3344 |
<button class="btn-icon" id="fitBtn" title="Khα»p toΓ n bα» dα»― liα»u">
|
|
|
|
| 3385 |
<div class="chart-bg-overlay"></div>
|
| 3386 |
<div class="chart-logo-overlay">KRONOS AI</div>
|
| 3387 |
|
| 3388 |
+
<!-- Workspace Grid Container -->
|
| 3389 |
+
<div id="workspaceGrid" class="workspace-grid layout-1">
|
| 3390 |
+
<!-- Pane 0 (default) -->
|
| 3391 |
+
<div class="chart-pane active" data-pane-id="pane-0">
|
| 3392 |
+
<div class="pane-header-mini">
|
| 3393 |
+
<span class="pane-symbol">XAUUSD</span>
|
| 3394 |
+
<span class="pane-sep">Β·</span>
|
| 3395 |
+
<span class="pane-interval">1d</span>
|
| 3396 |
+
<span class="pane-price">--</span>
|
| 3397 |
+
</div>
|
| 3398 |
+
<button class="pane-analysis-btn" type="button" data-pane-analysis="pane-0" data-state="idle" aria-label="PhΓ’n tΓch AI chart hiα»n tαΊ‘i">
|
| 3399 |
+
<span class="dot"></span>
|
| 3400 |
+
<span>AI</span>
|
| 3401 |
+
</button>
|
| 3402 |
+
<div class="pane-chart" id="chart"></div>
|
| 3403 |
+
<div class="pane-loader hidden"></div>
|
| 3404 |
+
<div class="pane-gauges"></div>
|
| 3405 |
+
<div class="pane-analysis-overlay"></div>
|
| 3406 |
+
</div>
|
| 3407 |
+
</div>
|
| 3408 |
|
| 3409 |
+
<!-- Compact Gauges Overlay (v6.0) β global, for active pane -->
|
| 3410 |
<div class="chart-gauges-container" id="chartGauges"></div>
|
| 3411 |
|
| 3412 |
<aside class="analysis-panel hidden" id="analysisPanel"></aside>
|
|
|
|
| 3446 |
</div>
|
| 3447 |
</div>
|
| 3448 |
|
| 3449 |
+
<!-- ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3450 |
+
WORKSPACE ENGINE (loaded first)
|
| 3451 |
+
ββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 3452 |
+
<script src="/workspace.js?v=__FRONTEND_ASSET_VERSION__"></script>
|
| 3453 |
+
|
| 3454 |
<!-- ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3455 |
JAVASCRIPT β all original logic preserved
|
| 3456 |
ββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 3457 |
<script>
|
| 3458 |
const API_BASE = (window.location.origin && window.location.origin !== 'null')
|
| 3459 |
? window.location.origin
|
| 3460 |
+
: '';
|
| 3461 |
const HEADER_VISIBILITY_KEY = 'kronos_header_visibility';
|
| 3462 |
+
window.__KRONOS_API_BASE = API_BASE;
|
| 3463 |
|
| 3464 |
/* ββ DOM refs ββββββββββββββββββββββββββββββββ */
|
| 3465 |
/* ββ DOM refs ββββββββββββββββββββββββββββββββ */
|
|
|
|
| 3671 |
});
|
| 3672 |
})();
|
| 3673 |
|
| 3674 |
+
function connectWS(symbol, interval = currentInterval || timeframeSelect.value) {
|
| 3675 |
+
if (window.Workspace && Workspace.layoutPreset > 1) {
|
| 3676 |
+
if (ws) {
|
| 3677 |
+
ws.close();
|
| 3678 |
+
ws = null;
|
| 3679 |
+
}
|
| 3680 |
+
return;
|
| 3681 |
+
}
|
| 3682 |
if (ws) {
|
| 3683 |
ws.close();
|
| 3684 |
ws = null;
|
| 3685 |
}
|
| 3686 |
|
| 3687 |
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 3688 |
+
const wsUrl = `${API_BASE.replace(/^https?:\/\//, wsProtocol)}/ws/price/${symbol}?interval=${encodeURIComponent(interval)}`;
|
| 3689 |
|
| 3690 |
console.log(`[WS] Connecting to ${wsUrl}`);
|
| 3691 |
ws = new WebSocket(wsUrl);
|
|
|
|
| 3702 |
|
| 3703 |
// Update current candle if price changed
|
| 3704 |
const lastCandle = lastCandleData;
|
| 3705 |
+
if (lastCandle && data.price && shouldMutateRealtimeCandle(symbol)) {
|
| 3706 |
const update = {
|
| 3707 |
time: lastCandle.time,
|
| 3708 |
open: lastCandle.open,
|
|
|
|
| 3720 |
console.log('[WS] Disconnected');
|
| 3721 |
// Reconnect after 5s if still active
|
| 3722 |
setTimeout(() => {
|
| 3723 |
+
if ((!window.Workspace || Workspace.layoutPreset === 1) && currentSymbol === symbol) connectWS(symbol, interval);
|
| 3724 |
}, 5000);
|
| 3725 |
};
|
| 3726 |
}
|
|
|
|
| 3782 |
}
|
| 3783 |
|
| 3784 |
/* ββ Switch Logic βββββββββββββββββββββββββββββ */
|
| 3785 |
+
async function switchSymbol(symbol, options = {}) {
|
| 3786 |
+
if (window.Workspace && Workspace.layoutPreset > 1 && !options.forcePrimary) {
|
| 3787 |
+
applySymbolToActivePane(symbol);
|
| 3788 |
+
return;
|
| 3789 |
+
}
|
| 3790 |
currentSymbol = symbol;
|
| 3791 |
symbolSearch.value = symbol;
|
| 3792 |
searchResults.classList.remove('visible');
|
|
|
|
| 3803 |
/* ββ Chart init ββββββββββββββββββββββββββββββ */
|
| 3804 |
const chartEl = document.getElementById('chart');
|
| 3805 |
|
| 3806 |
+
const CHART_RIGHT_OFFSET = 20;
|
| 3807 |
+
const FORECAST_PALETTE = {
|
| 3808 |
+
up: {
|
| 3809 |
+
line: '#16a34a',
|
| 3810 |
+
lineSoft: 'rgba(22, 163, 74, 0.34)',
|
| 3811 |
+
band: 'rgba(34, 197, 94, 0.34)',
|
| 3812 |
+
},
|
| 3813 |
+
down: {
|
| 3814 |
+
line: '#f87171',
|
| 3815 |
+
lineSoft: 'rgba(248, 113, 113, 0.32)',
|
| 3816 |
+
band: 'rgba(248, 113, 113, 0.28)',
|
| 3817 |
+
},
|
| 3818 |
+
flat: {
|
| 3819 |
+
line: '#eab308',
|
| 3820 |
+
lineSoft: 'rgba(234, 179, 8, 0.34)',
|
| 3821 |
+
band: 'rgba(250, 204, 21, 0.30)',
|
| 3822 |
+
},
|
| 3823 |
+
};
|
| 3824 |
+
|
| 3825 |
+
const LIVE_PRICE_ONLY_SYMBOLS = new Set([
|
| 3826 |
+
'USDX',
|
| 3827 |
+
'EURX',
|
| 3828 |
+
'GBPX',
|
| 3829 |
+
'CHFX',
|
| 3830 |
+
'JPYX',
|
| 3831 |
+
'CADX',
|
| 3832 |
+
'AUDX',
|
| 3833 |
+
'NZDX',
|
| 3834 |
+
]);
|
| 3835 |
+
|
| 3836 |
+
function shouldMutateRealtimeCandle(symbol) {
|
| 3837 |
+
return !LIVE_PRICE_ONLY_SYMBOLS.has(String(symbol || '').toUpperCase());
|
| 3838 |
+
}
|
| 3839 |
+
|
| 3840 |
+
function applyChartRightOffset(chartInstance, offset = CHART_RIGHT_OFFSET) {
|
| 3841 |
+
if (!chartInstance || typeof chartInstance.timeScale !== 'function') return;
|
| 3842 |
+
try {
|
| 3843 |
+
chartInstance.applyOptions({
|
| 3844 |
+
timeScale: {
|
| 3845 |
+
rightOffset: offset,
|
| 3846 |
+
},
|
| 3847 |
+
});
|
| 3848 |
+
if (typeof chartInstance.timeScale().scrollToPosition === 'function') {
|
| 3849 |
+
chartInstance.timeScale().scrollToPosition(offset, false);
|
| 3850 |
+
}
|
| 3851 |
+
} catch (error) {
|
| 3852 |
+
console.warn('[chart] applyChartRightOffset failed', error);
|
| 3853 |
+
}
|
| 3854 |
+
}
|
| 3855 |
+
|
| 3856 |
+
function fitChartWithOffset(chartInstance, offset = CHART_RIGHT_OFFSET) {
|
| 3857 |
+
if (!chartInstance || typeof chartInstance.timeScale !== 'function') return;
|
| 3858 |
+
chartInstance.timeScale().fitContent();
|
| 3859 |
+
applyChartRightOffset(chartInstance, offset);
|
| 3860 |
+
}
|
| 3861 |
+
|
| 3862 |
+
function getForecastRightOffset(points) {
|
| 3863 |
+
const length = Array.isArray(points) ? points.length : 0;
|
| 3864 |
+
return Math.max(CHART_RIGHT_OFFSET, Math.min(64, 18 + (length * 3)));
|
| 3865 |
+
}
|
| 3866 |
+
|
| 3867 |
+
function getForecastSegmentColor(diff, epsilon = 0.0001) {
|
| 3868 |
+
if (diff > epsilon) return FORECAST_PALETTE.up.line;
|
| 3869 |
+
if (diff < -epsilon) return FORECAST_PALETTE.down.line;
|
| 3870 |
+
return FORECAST_PALETTE.flat.line;
|
| 3871 |
+
}
|
| 3872 |
+
|
| 3873 |
+
function getForecastTone(points) {
|
| 3874 |
+
if (!Array.isArray(points) || points.length < 2) return 'flat';
|
| 3875 |
+
const first = Number(points[0]?.value ?? 0);
|
| 3876 |
+
const last = Number(points[points.length - 1]?.value ?? first);
|
| 3877 |
+
const baseline = Math.max(Math.abs(first), 1);
|
| 3878 |
+
const delta = last - first;
|
| 3879 |
+
const epsilon = baseline * 0.0006;
|
| 3880 |
+
if (delta > epsilon) return 'up';
|
| 3881 |
+
if (delta < -epsilon) return 'down';
|
| 3882 |
+
return 'flat';
|
| 3883 |
+
}
|
| 3884 |
+
|
| 3885 |
+
function buildForecastCandleSeriesOptions(tone = 'flat') {
|
| 3886 |
+
const palette = FORECAST_PALETTE[tone] || FORECAST_PALETTE.flat;
|
| 3887 |
+
return {
|
| 3888 |
+
upColor: tone === 'flat' ? 'rgba(234, 179, 8, 0.50)' : 'rgba(22, 163, 74, 0.50)',
|
| 3889 |
+
downColor: tone === 'flat' ? 'rgba(250, 204, 21, 0.50)' : 'rgba(248, 113, 113, 0.50)',
|
| 3890 |
+
borderVisible: false,
|
| 3891 |
+
wickUpColor: tone === 'flat' ? 'rgba(234, 179, 8, 0.50)' : 'rgba(22, 163, 74, 0.50)',
|
| 3892 |
+
wickDownColor: tone === 'flat' ? 'rgba(250, 204, 21, 0.50)' : 'rgba(248, 113, 113, 0.50)',
|
| 3893 |
+
priceLineVisible: false,
|
| 3894 |
+
lastValueVisible: false,
|
| 3895 |
+
visible: false,
|
| 3896 |
+
};
|
| 3897 |
+
}
|
| 3898 |
+
|
| 3899 |
+
function normalizeForecastCandles(candles) {
|
| 3900 |
+
if (!Array.isArray(candles)) return [];
|
| 3901 |
+
return candles
|
| 3902 |
+
.map((candle) => {
|
| 3903 |
+
const time = Number(candle?.time);
|
| 3904 |
+
const open = Number(candle?.open);
|
| 3905 |
+
const high = Number(candle?.high);
|
| 3906 |
+
const low = Number(candle?.low);
|
| 3907 |
+
const close = Number(candle?.close);
|
| 3908 |
+
if (![time, open, high, low, close].every(Number.isFinite)) return null;
|
| 3909 |
+
const upper = Math.max(open, high, low, close);
|
| 3910 |
+
const lower = Math.min(open, high, low, close);
|
| 3911 |
+
return {
|
| 3912 |
+
time,
|
| 3913 |
+
open,
|
| 3914 |
+
high: upper,
|
| 3915 |
+
low: lower,
|
| 3916 |
+
close,
|
| 3917 |
+
};
|
| 3918 |
+
})
|
| 3919 |
+
.filter(Boolean);
|
| 3920 |
+
}
|
| 3921 |
+
|
| 3922 |
+
function buildForecastClosePath(anchorPoint, candles) {
|
| 3923 |
+
const futurePoints = normalizeForecastCandles(candles).map((candle) => ({
|
| 3924 |
+
time: candle.time,
|
| 3925 |
+
value: candle.close,
|
| 3926 |
+
}));
|
| 3927 |
+
return anchorPoint ? [anchorPoint, ...futurePoints] : futurePoints;
|
| 3928 |
+
}
|
| 3929 |
+
|
| 3930 |
+
function buildForecastLineFromRows(rows, fallbackActualPoint = null) {
|
| 3931 |
+
const points = Array.isArray(rows)
|
| 3932 |
+
? rows
|
| 3933 |
+
.filter((row) => row && row.time !== undefined && row.p50 !== undefined)
|
| 3934 |
+
.map((row) => ({
|
| 3935 |
+
time: Number(row.time),
|
| 3936 |
+
value: Number(row.p50),
|
| 3937 |
+
}))
|
| 3938 |
+
.filter((point) => Number.isFinite(point.time) && Number.isFinite(point.value))
|
| 3939 |
+
: [];
|
| 3940 |
+
|
| 3941 |
+
if (!fallbackActualPoint) {
|
| 3942 |
+
return points;
|
| 3943 |
+
}
|
| 3944 |
+
if (!points.length) {
|
| 3945 |
+
return [fallbackActualPoint];
|
| 3946 |
+
}
|
| 3947 |
+
if (points[0].time === fallbackActualPoint.time) {
|
| 3948 |
+
return points;
|
| 3949 |
+
}
|
| 3950 |
+
return [fallbackActualPoint, ...points];
|
| 3951 |
+
}
|
| 3952 |
+
|
| 3953 |
+
window.normalizeForecastCandles = normalizeForecastCandles;
|
| 3954 |
+
window.buildForecastLineFromRows = buildForecastLineFromRows;
|
| 3955 |
+
|
| 3956 |
const chart = LightweightCharts.createChart(chartEl, {
|
| 3957 |
layout: {
|
| 3958 |
background: { type: 'solid', color: 'transparent' },
|
|
|
|
| 3975 |
secondsVisible: false,
|
| 3976 |
fixLeftEdge: false,
|
| 3977 |
fixRightEdge: false,
|
| 3978 |
+
rightOffset: CHART_RIGHT_OFFSET,
|
| 3979 |
},
|
| 3980 |
crosshair: {
|
| 3981 |
mode: LightweightCharts.CrosshairMode.Normal,
|
|
|
|
| 4011 |
wickDownColor: '#e05560',
|
| 4012 |
});
|
| 4013 |
|
| 4014 |
+
const forecastCandleSeries = chart.addCandlestickSeries(buildForecastCandleSeriesOptions());
|
| 4015 |
+
|
| 4016 |
const p50Series = chart.addLineSeries({
|
| 4017 |
color: '#66d9ff',
|
| 4018 |
lineWidth: 2,
|
|
|
|
| 4054 |
const prev = points[i - 1];
|
| 4055 |
const curr = points[i];
|
| 4056 |
const diff = (curr?.value ?? 0) - (prev?.value ?? 0);
|
| 4057 |
+
// TΔng: xanh lΓ‘ rΓ΅ hΖ‘n, GiαΊ£m: Δα» dα»u hΖ‘n, Δi ngang: vΓ ng
|
| 4058 |
+
const color = getForecastSegmentColor(diff, EPSILON);
|
| 4059 |
const segSeries = chart.addLineSeries({
|
| 4060 |
color,
|
| 4061 |
lineWidth: 2,
|
|
|
|
| 4079 |
});
|
| 4080 |
|
| 4081 |
/* ββ Indicator Series ββββββββββββββββββββββββ */
|
| 4082 |
+
const bbMiddleSeries = chart.addLineSeries({ color: 'rgba(255, 255, 255, 0.2)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false });
|
| 4083 |
+
const bbUpperSeries = chart.addLineSeries({ color: 'rgba(34, 211, 238, 0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false });
|
| 4084 |
+
const bbLowerSeries = chart.addLineSeries({ color: 'rgba(34, 211, 238, 0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false });
|
| 4085 |
|
| 4086 |
let rsiSeries = null;
|
| 4087 |
|
|
|
|
| 4119 |
}
|
| 4120 |
}
|
| 4121 |
|
| 4122 |
+
function setGlobalStatusVisibility(visible) {
|
| 4123 |
+
const statusWrap = statusPill ? statusPill.closest('.status-wrap') : null;
|
| 4124 |
+
if (!statusWrap) return;
|
| 4125 |
+
statusWrap.style.display = visible ? '' : 'none';
|
| 4126 |
+
}
|
| 4127 |
+
|
| 4128 |
+
function setGlobalCompactGaugesVisibility(visible) {
|
| 4129 |
+
const container = document.getElementById('chartGauges');
|
| 4130 |
+
if (!container) return;
|
| 4131 |
+
container.style.display = visible ? '' : 'none';
|
| 4132 |
+
if (!visible) {
|
| 4133 |
+
container.innerHTML = '';
|
| 4134 |
+
container.classList.remove('combo-active');
|
| 4135 |
+
}
|
| 4136 |
+
}
|
| 4137 |
+
|
| 4138 |
function dimChartForRefresh() {
|
| 4139 |
// Show loading placeholder on chart
|
| 4140 |
const chartContainer = document.getElementById('chart-container');
|
|
|
|
| 4286 |
function syncChartPriceFormat(symbol, candles = []) {
|
| 4287 |
currentPriceFormat = resolvePriceFormat(symbol, candles);
|
| 4288 |
applyPriceFormatToSeries(candleSeries);
|
| 4289 |
+
applyPriceFormatToSeries(forecastCandleSeries);
|
| 4290 |
applyPriceFormatToSeries(p50Series);
|
| 4291 |
applyPriceFormatToSeries(p10Series);
|
| 4292 |
applyPriceFormatToSeries(p90Series);
|
|
|
|
| 4378 |
|
| 4379 |
function renderCompactGauges(symbol, interval, payload) {
|
| 4380 |
const container = document.getElementById('chartGauges');
|
| 4381 |
+
if (!container) {
|
| 4382 |
+
return;
|
| 4383 |
+
}
|
| 4384 |
+
|
| 4385 |
+
if (window.Workspace && Workspace.layoutPreset > 1) {
|
| 4386 |
+
container.innerHTML = '';
|
| 4387 |
+
container.classList.remove('combo-active');
|
| 4388 |
+
container.style.display = 'none';
|
| 4389 |
+
return;
|
| 4390 |
+
}
|
| 4391 |
+
|
| 4392 |
+
container.style.display = '';
|
| 4393 |
+
if (!payload?.analysis) {
|
| 4394 |
+
container.innerHTML = '';
|
| 4395 |
+
container.classList.remove('combo-active');
|
| 4396 |
return;
|
| 4397 |
}
|
| 4398 |
|
|
|
|
| 4660 |
}
|
| 4661 |
|
| 4662 |
function setPrimaryForecastVisibility(visible) {
|
| 4663 |
+
p50Series.applyOptions({ visible });
|
| 4664 |
+
p10Series.applyOptions({ visible: false });
|
| 4665 |
+
p90Series.applyOptions({ visible: false });
|
| 4666 |
+
forecastCandleSeries.applyOptions({ visible: false });
|
| 4667 |
}
|
| 4668 |
|
| 4669 |
function clearForecastVisuals() {
|
| 4670 |
+
forecastCandleSeries.setData([]);
|
| 4671 |
p50Series.setData([]);
|
| 4672 |
p10Series.setData([]);
|
| 4673 |
p90Series.setData([]);
|
|
|
|
| 4676 |
activeForecastContext = { symbol: null, interval: null, ready: false };
|
| 4677 |
}
|
| 4678 |
|
| 4679 |
+
function clearPrimaryForecastCandlesOnly() {
|
| 4680 |
+
p50Series.setData([]);
|
| 4681 |
+
p10Series.setData([]);
|
| 4682 |
+
p90Series.setData([]);
|
| 4683 |
+
clearForecastSegments();
|
| 4684 |
+
forecastCandleSeries.setData([]);
|
| 4685 |
+
setPrimaryForecastVisibility(false);
|
| 4686 |
+
forecastCandleSeries.applyOptions({
|
| 4687 |
+
...buildForecastCandleSeriesOptions(),
|
| 4688 |
+
visible: false,
|
| 4689 |
+
priceFormat: buildSeriesPriceFormat(),
|
| 4690 |
+
});
|
| 4691 |
+
}
|
| 4692 |
+
|
| 4693 |
+
function commitForecastVisuals(symbol, interval, forecastLine = []) {
|
| 4694 |
+
const points = Array.isArray(forecastLine) ? forecastLine : [];
|
| 4695 |
+
const tone = getForecastTone(points);
|
| 4696 |
+
const palette = FORECAST_PALETTE[tone] || FORECAST_PALETTE.flat;
|
| 4697 |
+
forecastCandleSeries.setData([]);
|
| 4698 |
+
forecastCandleSeries.applyOptions({
|
| 4699 |
+
...buildForecastCandleSeriesOptions(tone),
|
| 4700 |
+
visible: false,
|
| 4701 |
+
priceFormat: buildSeriesPriceFormat(),
|
| 4702 |
+
});
|
| 4703 |
+
|
| 4704 |
p50Series.setData([]);
|
| 4705 |
+
p10Series.setData([]);
|
| 4706 |
+
p90Series.setData([]);
|
| 4707 |
+
clearForecastSegments();
|
| 4708 |
+
p50Series.applyOptions({
|
| 4709 |
+
color: palette.line,
|
| 4710 |
+
lineWidth: 2,
|
| 4711 |
+
priceFormat: buildSeriesPriceFormat(),
|
| 4712 |
+
visible: points.length > 0,
|
| 4713 |
+
});
|
| 4714 |
+
setPrimaryForecastVisibility(points.length > 0);
|
| 4715 |
+
p50Series.setData(points);
|
| 4716 |
+
fitChartWithOffset(chart, getForecastRightOffset(points));
|
| 4717 |
activeForecastContext = { symbol, interval, ready: true };
|
| 4718 |
}
|
| 4719 |
|
| 4720 |
+
function clearPaneForecastSegments(pane) {
|
| 4721 |
+
if (!pane?.chartInstance) return;
|
| 4722 |
+
const segments = Array.isArray(pane.forecastSeries?.segments) ? pane.forecastSeries.segments : [];
|
| 4723 |
+
if (!segments.length) {
|
| 4724 |
+
if (pane?.forecastSeries) pane.forecastSeries.segments = [];
|
| 4725 |
+
return;
|
| 4726 |
+
}
|
| 4727 |
+
for (const series of segments) {
|
| 4728 |
+
try {
|
| 4729 |
+
pane.chartInstance.removeSeries(series);
|
| 4730 |
+
} catch (error) {
|
| 4731 |
+
console.warn(`[Pane ${pane.paneId}] clear forecast segment failed`, error);
|
| 4732 |
+
}
|
| 4733 |
+
}
|
| 4734 |
+
pane.forecastSeries.segments = [];
|
| 4735 |
+
}
|
| 4736 |
+
|
| 4737 |
+
function clearPaneForecastVisuals(pane) {
|
| 4738 |
+
if (!pane?.forecastSeries) return;
|
| 4739 |
+
clearPaneForecastSegments(pane);
|
| 4740 |
+
if (pane.forecastSeries.p50) {
|
| 4741 |
+
pane.forecastSeries.p50.setData([]);
|
| 4742 |
+
pane.forecastSeries.p50.applyOptions({
|
| 4743 |
+
visible: false,
|
| 4744 |
+
color: FORECAST_PALETTE.flat.line,
|
| 4745 |
+
});
|
| 4746 |
+
}
|
| 4747 |
+
if (pane.forecastSeries.p10) {
|
| 4748 |
+
pane.forecastSeries.p10.setData([]);
|
| 4749 |
+
pane.forecastSeries.p10.applyOptions({ color: FORECAST_PALETTE.flat.band, visible: false });
|
| 4750 |
+
}
|
| 4751 |
+
if (pane.forecastSeries.p90) {
|
| 4752 |
+
pane.forecastSeries.p90.setData([]);
|
| 4753 |
+
pane.forecastSeries.p90.applyOptions({ color: FORECAST_PALETTE.flat.band, visible: false });
|
| 4754 |
+
}
|
| 4755 |
+
if (pane.forecastSeries.candles) {
|
| 4756 |
+
pane.forecastSeries.candles.setData([]);
|
| 4757 |
+
pane.forecastSeries.candles.applyOptions({ ...buildForecastCandleSeriesOptions(), visible: false });
|
| 4758 |
+
}
|
| 4759 |
+
pane.forecastContext = { symbol: null, interval: null, ready: false };
|
| 4760 |
+
}
|
| 4761 |
+
|
| 4762 |
+
function clearPaneForecastCandlesOnly(pane) {
|
| 4763 |
+
if (!pane?.forecastSeries) return;
|
| 4764 |
+
clearPaneForecastVisuals(pane);
|
| 4765 |
+
}
|
| 4766 |
+
|
| 4767 |
+
function resetPendingPaneAI(pane) {
|
| 4768 |
+
if (!pane) return;
|
| 4769 |
+
if (pane.analysisFetchController) {
|
| 4770 |
+
pane.analysisFetchController.abort();
|
| 4771 |
+
pane.analysisFetchController = null;
|
| 4772 |
+
}
|
| 4773 |
+
if (pane.analysisRetryTimer) {
|
| 4774 |
+
clearTimeout(pane.analysisRetryTimer);
|
| 4775 |
+
pane.analysisRetryTimer = null;
|
| 4776 |
+
}
|
| 4777 |
+
pane.analysisRequestPromise = null;
|
| 4778 |
+
pane.analysisRequestKey = null;
|
| 4779 |
+
clearPaneForecastCandlesOnly(pane);
|
| 4780 |
+
}
|
| 4781 |
+
|
| 4782 |
+
function renderPaneForecastVisuals(pane, forecastLine = []) {
|
| 4783 |
+
if (!pane?.forecastSeries || !pane?.chartInstance) return;
|
| 4784 |
+
|
| 4785 |
+
const points = Array.isArray(forecastLine) ? forecastLine : [];
|
| 4786 |
+
const tone = getForecastTone(points);
|
| 4787 |
+
const palette = FORECAST_PALETTE[tone] || FORECAST_PALETTE.flat;
|
| 4788 |
+
clearPaneForecastSegments(pane);
|
| 4789 |
+
|
| 4790 |
+
if (pane.forecastSeries.p10) {
|
| 4791 |
+
pane.forecastSeries.p10.setData([]);
|
| 4792 |
+
pane.forecastSeries.p10.applyOptions({ color: palette.band, visible: false });
|
| 4793 |
+
}
|
| 4794 |
+
if (pane.forecastSeries.p90) {
|
| 4795 |
+
pane.forecastSeries.p90.setData([]);
|
| 4796 |
+
pane.forecastSeries.p90.applyOptions({ color: palette.band, visible: false });
|
| 4797 |
+
}
|
| 4798 |
+
|
| 4799 |
+
if (pane.forecastSeries.candles) {
|
| 4800 |
+
pane.forecastSeries.candles.setData([]);
|
| 4801 |
+
pane.forecastSeries.candles.applyOptions({
|
| 4802 |
+
...buildForecastCandleSeriesOptions(tone),
|
| 4803 |
+
visible: false,
|
| 4804 |
+
priceFormat: {
|
| 4805 |
+
type: 'price',
|
| 4806 |
+
precision: pane.priceFormat?.precision ?? 2,
|
| 4807 |
+
minMove: pane.priceFormat?.minMove ?? 0.01,
|
| 4808 |
+
},
|
| 4809 |
+
});
|
| 4810 |
+
}
|
| 4811 |
+
|
| 4812 |
+
if (!points.length || !pane.forecastSeries.p50) {
|
| 4813 |
+
fitChartWithOffset(pane.chartInstance);
|
| 4814 |
+
return;
|
| 4815 |
+
}
|
| 4816 |
+
|
| 4817 |
+
pane.forecastSeries.p50.setData(points);
|
| 4818 |
+
pane.forecastSeries.p50.applyOptions({
|
| 4819 |
+
color: palette.line,
|
| 4820 |
+
visible: true,
|
| 4821 |
+
lineWidth: 2,
|
| 4822 |
+
lineStyle: 0,
|
| 4823 |
+
priceLineVisible: false,
|
| 4824 |
+
lastValueVisible: false,
|
| 4825 |
+
crosshairMarkerVisible: true,
|
| 4826 |
+
priceFormat: {
|
| 4827 |
+
type: 'price',
|
| 4828 |
+
precision: pane.priceFormat?.precision ?? 2,
|
| 4829 |
+
minMove: pane.priceFormat?.minMove ?? 0.01,
|
| 4830 |
+
},
|
| 4831 |
+
});
|
| 4832 |
+
fitChartWithOffset(pane.chartInstance, getForecastRightOffset(points));
|
| 4833 |
+
}
|
| 4834 |
+
|
| 4835 |
+
window.clearPaneForecastVisuals = clearPaneForecastVisuals;
|
| 4836 |
+
window.clearPaneForecastCandlesOnly = clearPaneForecastCandlesOnly;
|
| 4837 |
+
window.renderPaneForecastVisuals = renderPaneForecastVisuals;
|
| 4838 |
+
|
| 4839 |
function scheduleAnalysisRetry(symbol, interval) {
|
| 4840 |
if (analysisRetryTimer) return;
|
| 4841 |
analysisRetryTimer = setTimeout(() => {
|
|
|
|
| 4967 |
const indicators = indData.indicators || {};
|
| 4968 |
const series = indicators.series || {};
|
| 4969 |
|
| 4970 |
+
if (series.bb_upper) bbUpperSeries.setData(series.bb_upper);
|
| 4971 |
+
if (series.bb_mid) bbMiddleSeries.setData(series.bb_mid);
|
| 4972 |
+
if (series.bb_lower) bbLowerSeries.setData(series.bb_lower);
|
|
|
|
|
|
|
| 4973 |
|
| 4974 |
+
fitChartWithOffset(chart);
|
| 4975 |
activeChartContext = { symbol, interval };
|
| 4976 |
+
if (window.Workspace && typeof Workspace.getPane === 'function') {
|
| 4977 |
+
const pane0 = Workspace.getPane('pane-0');
|
| 4978 |
+
if (pane0) {
|
| 4979 |
+
pane0.symbol = symbol;
|
| 4980 |
+
pane0.interval = interval;
|
| 4981 |
+
pane0.lastCandleData = lastCandleData;
|
| 4982 |
+
pane0.priceFormat = currentPriceFormat;
|
| 4983 |
+
if (pane0.priceEl && lastCandleData) {
|
| 4984 |
+
pane0.priceEl.textContent = Number(lastCandleData.close).toFixed(currentPriceFormat.precision);
|
| 4985 |
+
}
|
| 4986 |
+
renderPaneAnalysisUI(pane0);
|
| 4987 |
+
if (typeof pane0.fetchAI === 'function') {
|
| 4988 |
+
pane0.fetchAI({ force: true });
|
| 4989 |
+
}
|
| 4990 |
+
}
|
| 4991 |
+
}
|
| 4992 |
hideLoader();
|
| 4993 |
updateStatus(
|
| 4994 |
hasLiveForecastFor(symbol, interval)
|
|
|
|
| 4996 |
: `${symbol} | ${interval} - Dang nap AI...`,
|
| 4997 |
'loading'
|
| 4998 |
);
|
|
|
|
| 4999 |
} catch (e) {
|
| 5000 |
if (e.name === 'AbortError') return;
|
| 5001 |
console.error('Stage 1 Fetch Error:', e);
|
|
|
|
| 5009 |
}
|
| 5010 |
|
| 5011 |
async function fetchAIAnalysis(symbol, interval, options = {}) {
|
| 5012 |
+
// DISABLED: Handled per-pane by Workspace.PaneState.fetchAI
|
| 5013 |
+
return null;
|
| 5014 |
if (currentSymbol !== symbol || timeframeSelect.value !== interval) return null;
|
| 5015 |
|
| 5016 |
const panel = document.getElementById('analysisPanel');
|
|
|
|
| 5037 |
analysisFetchController.abort();
|
| 5038 |
}
|
| 5039 |
|
| 5040 |
+
clearPrimaryForecastCandlesOnly();
|
| 5041 |
+
|
| 5042 |
const controller = new AbortController();
|
| 5043 |
analysisFetchController = controller;
|
| 5044 |
analysisRequestKey = requestKey;
|
|
|
|
| 5052 |
|
| 5053 |
if (currentSymbol !== symbol || timeframeSelect.value !== interval) return null;
|
| 5054 |
|
| 5055 |
+
const forecastPoints = Array.isArray(fData.forecast) ? fData.forecast : [];
|
| 5056 |
const hasAnalysis = Boolean(fData.analysis);
|
| 5057 |
const hadForecast = hasLiveForecastFor(symbol, interval);
|
| 5058 |
+
const fallbackActualPoint = lastCandleData && lastCandleData.time
|
| 5059 |
+
? {
|
| 5060 |
+
time: lastCandleData.time,
|
| 5061 |
+
value: (
|
| 5062 |
+
Number(lastCandleData.open ?? 0)
|
| 5063 |
+
+ Number(lastCandleData.high ?? 0)
|
| 5064 |
+
+ Number(lastCandleData.low ?? 0)
|
| 5065 |
+
+ Number(lastCandleData.close ?? 0)
|
| 5066 |
+
) / 4,
|
| 5067 |
+
}
|
| 5068 |
+
: null;
|
| 5069 |
+
const forecastLine = buildForecastLineFromRows(forecastPoints, fallbackActualPoint);
|
| 5070 |
+
const hasForecast = forecastLine.length > 1;
|
| 5071 |
|
| 5072 |
if (!hasForecast || !lastCandleData || !lastCandleData.time) {
|
| 5073 |
if (!hadForecast) {
|
|
|
|
| 5088 |
return fData;
|
| 5089 |
}
|
| 5090 |
|
| 5091 |
+
commitForecastVisuals(symbol, interval, forecastLine);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5092 |
|
| 5093 |
renderAnalysisPanel(symbol, interval, fData);
|
| 5094 |
setTimeout(updateDashboardScale, 10);
|
| 5095 |
renderCompactGauges(symbol, interval, fData);
|
|
|
|
| 5096 |
|
| 5097 |
const currentPrice = lastCandleData?.close || 0;
|
| 5098 |
+
const forecastBaseVal = Number(forecastLine[0]?.value ?? currentPrice);
|
| 5099 |
+
const lastForecastVal = Number(forecastLine[forecastLine.length - 1]?.value ?? forecastBaseVal);
|
| 5100 |
+
const isBull = lastForecastVal >= forecastBaseVal;
|
| 5101 |
const pctChange = currentPrice > 0 ? ((lastForecastVal - currentPrice) / currentPrice) * 100 : 0;
|
| 5102 |
const pctLabel = (pctChange >= 0 ? '+' : '') + pctChange.toFixed(2) + '%';
|
| 5103 |
const trend = isBull ? 'TANG' : 'GIAM';
|
|
|
|
| 5236 |
closeExplorerBtn.onclick = closeExplorer;
|
| 5237 |
toggleMarketBtn.onclick = openExplorer;
|
| 5238 |
|
| 5239 |
+
function setPaneAnalysisOpen(pane, isOpen) {
|
| 5240 |
+
if (!pane) return;
|
| 5241 |
+
pane.analysisOpen = Boolean(isOpen);
|
| 5242 |
+
if (pane.analysisOverlayEl) pane.analysisOverlayEl.classList.toggle('active', pane.analysisOpen);
|
| 5243 |
+
if (pane.analysisButtonEl) pane.analysisButtonEl.classList.toggle('active', pane.analysisOpen);
|
| 5244 |
+
}
|
| 5245 |
+
|
| 5246 |
+
function buildPaneAnalysisMarkup(pane) {
|
| 5247 |
+
const payload = pane?.lastAnalysis?.payload;
|
| 5248 |
+
if (pane?.analysisFetchController && !payload) {
|
| 5249 |
+
return `<div class="pane-analysis-loading">AI Δang phΓ’n tΓch ${pane.symbol} ${pane.interval}...</div>`;
|
| 5250 |
+
}
|
| 5251 |
+
if (!payload || !payload.analysis) {
|
| 5252 |
+
return `<div class="pane-analysis-empty">ChΖ°a cΓ³ dα»― liα»u phΓ’n tΓch cho ${pane?.symbol || '--'}.</div>`;
|
| 5253 |
+
}
|
| 5254 |
+
|
| 5255 |
+
const analysis = payload.analysis;
|
| 5256 |
+
const summary = analysis.dashboard?.summary || analysis.summary || {};
|
| 5257 |
+
const technical = analysis.dashboard?.technical || analysis.technicals || {};
|
| 5258 |
+
const ai = analysis.dashboard?.ai || analysis.ai_gauge || {};
|
| 5259 |
+
const forecast = Array.isArray(payload.forecast) ? payload.forecast : [];
|
| 5260 |
+
const lastPoint = forecast.length ? forecast[forecast.length - 1] : null;
|
| 5261 |
+
const precision = pane.priceFormat?.precision ?? 2;
|
| 5262 |
+
|
| 5263 |
+
return `
|
| 5264 |
+
<div class="pane-analysis-title">
|
| 5265 |
+
<span>${pane.symbol} AI</span>
|
| 5266 |
+
<div class="pane-analysis-meta">
|
| 5267 |
+
<span class="pane-analysis-pill">${pane.interval.toUpperCase()} β’ H${pane.horizon || 10}</span>
|
| 5268 |
+
<span class="pane-analysis-pill">${payload.verdict || summary.signal || 'NEUTRAL'}</span>
|
| 5269 |
+
</div>
|
| 5270 |
+
</div>
|
| 5271 |
+
<div class="pane-analysis-grid">
|
| 5272 |
+
<div class="pane-analysis-card">
|
| 5273 |
+
<strong>Tα»ng quan</strong>
|
| 5274 |
+
<div class="pane-analysis-body">${summary.narrative || summary.reason || analysis.summary_text || 'AI Δang theo dΓ΅i diα»
n biαΊΏn hiα»n tαΊ‘i cα»§a chart nΓ y.'}</div>
|
| 5275 |
+
</div>
|
| 5276 |
+
<div class="pane-analysis-card">
|
| 5277 |
+
<strong>Dα»± bΓ‘o</strong>
|
| 5278 |
+
<div class="pane-analysis-body">${lastPoint ? `P50: ${Number(lastPoint.p50 ?? 0).toFixed(precision)} | P10: ${Number(lastPoint.p10 ?? 0).toFixed(precision)} | P90: ${Number(lastPoint.p90 ?? 0).toFixed(precision)}` : 'ChΖ°a cΓ³ dαΊ£i dα»± bΓ‘o.'}</div>
|
| 5279 |
+
</div>
|
| 5280 |
+
<div class="pane-analysis-card">
|
| 5281 |
+
<strong>Technical</strong>
|
| 5282 |
+
<div class="pane-analysis-body">Trend score: ${Math.round(Number(technical.score ?? technical.trend_score ?? 50))}</div>
|
| 5283 |
+
</div>
|
| 5284 |
+
<div class="pane-analysis-card">
|
| 5285 |
+
<strong>AI Score</strong>
|
| 5286 |
+
<div class="pane-analysis-body">Confidence: ${Math.round(Number(ai.score ?? ai.confidence ?? summary.confidence ?? 50))}</div>
|
| 5287 |
+
</div>
|
| 5288 |
+
</div>
|
| 5289 |
+
`;
|
| 5290 |
+
}
|
| 5291 |
+
|
| 5292 |
+
function renderPaneAnalysisUI(pane) {
|
| 5293 |
+
if (!pane) return;
|
| 5294 |
+
if (pane.analysisButtonEl) {
|
| 5295 |
+
let state = 'idle';
|
| 5296 |
+
if (pane.analysisFetchController) state = 'loading';
|
| 5297 |
+
else if (pane.lastAnalysis && pane.lastAnalysis.payload) state = 'ready';
|
| 5298 |
+
else if (pane.error) state = 'error';
|
| 5299 |
+
pane.analysisButtonEl.dataset.state = state;
|
| 5300 |
+
pane.analysisButtonEl.setAttribute('aria-label', `PhΓ’n tΓch ${pane.symbol} ${pane.interval}`);
|
| 5301 |
+
}
|
| 5302 |
+
if (pane.analysisOverlayEl) {
|
| 5303 |
+
pane.analysisOverlayEl.innerHTML = buildPaneAnalysisMarkup(pane);
|
| 5304 |
+
pane.analysisOverlayEl.classList.toggle('active', Boolean(pane.analysisOpen));
|
| 5305 |
+
}
|
| 5306 |
+
}
|
| 5307 |
+
|
| 5308 |
+
window.renderPaneAnalysisUI = renderPaneAnalysisUI;
|
| 5309 |
+
|
| 5310 |
+
function bindPaneAnalysisButton(pane) {
|
| 5311 |
+
if (!pane?.analysisButtonEl) return;
|
| 5312 |
+
pane.analysisButtonEl.onclick = (event) => {
|
| 5313 |
+
event.stopPropagation();
|
| 5314 |
+
if (window.Workspace && typeof Workspace.setActivePane === 'function') {
|
| 5315 |
+
Workspace.setActivePane(pane.paneId);
|
| 5316 |
+
}
|
| 5317 |
+
const willOpen = !pane.analysisOpen;
|
| 5318 |
+
if (window.Workspace && Workspace.panes) {
|
| 5319 |
+
Workspace.panes.forEach((otherPane) => {
|
| 5320 |
+
if (otherPane !== pane) {
|
| 5321 |
+
setPaneAnalysisOpen(otherPane, false);
|
| 5322 |
+
renderPaneAnalysisUI(otherPane);
|
| 5323 |
+
}
|
| 5324 |
+
});
|
| 5325 |
+
}
|
| 5326 |
+
setPaneAnalysisOpen(pane, willOpen);
|
| 5327 |
+
renderPaneAnalysisUI(pane);
|
| 5328 |
+
if (willOpen && (!pane.lastAnalysis || !pane.lastAnalysis.payload) && typeof pane.fetchAI === 'function') {
|
| 5329 |
+
pane.fetchAI({ force: true });
|
| 5330 |
+
}
|
| 5331 |
+
};
|
| 5332 |
+
}
|
| 5333 |
+
|
| 5334 |
+
timeframeSelect.onchange = () => {
|
| 5335 |
+
const nextInterval = timeframeSelect.value;
|
| 5336 |
+
if (window.Workspace && Workspace.layoutPreset > 1) {
|
| 5337 |
+
const activePaneId = Workspace.activePaneId;
|
| 5338 |
+
const promises = [];
|
| 5339 |
+
currentInterval = nextInterval;
|
| 5340 |
+
|
| 5341 |
+
Workspace.panes.forEach((pane) => {
|
| 5342 |
+
resetPendingPaneAI(pane);
|
| 5343 |
+
pane.interval = nextInterval;
|
| 5344 |
+
if (pane.paneHeaderEl) {
|
| 5345 |
+
const intEl = pane.paneHeaderEl.querySelector('.pane-interval');
|
| 5346 |
+
if (intEl) intEl.textContent = nextInterval;
|
| 5347 |
+
}
|
| 5348 |
+
StreamManager.unsubscribe(pane.paneId);
|
| 5349 |
+
promises.push(loadPaneData(pane).then(() => connectPaneWS(pane)));
|
| 5350 |
+
});
|
| 5351 |
+
|
| 5352 |
+
Promise.allSettled(promises).then(() => {
|
| 5353 |
+
if (activePaneId && Workspace.panes.has(activePaneId)) {
|
| 5354 |
+
syncToolbarToPane(activePaneId);
|
| 5355 |
+
}
|
| 5356 |
+
});
|
| 5357 |
+
Workspace.save();
|
| 5358 |
+
return;
|
| 5359 |
+
}
|
| 5360 |
+
refreshChart({ forceContextReset: true });
|
| 5361 |
+
connectWS(currentSymbol);
|
| 5362 |
+
};
|
| 5363 |
+
|
| 5364 |
+
horizonInput.onchange = () => {
|
| 5365 |
+
const horizon = Math.max(5, Math.min(300, parseInt(horizonInput.value, 10) || 10));
|
| 5366 |
+
if (window.Workspace && Workspace.panes) {
|
| 5367 |
+
Workspace.panes.forEach((pane) => {
|
| 5368 |
+
pane.horizon = horizon;
|
| 5369 |
+
if (typeof pane.fetchAI === 'function') {
|
| 5370 |
+
pane.fetchAI({ force: true });
|
| 5371 |
+
}
|
| 5372 |
+
});
|
| 5373 |
+
Workspace.save();
|
| 5374 |
+
}
|
| 5375 |
+
};
|
| 5376 |
+
indicatorSelect.onchange = () => {
|
| 5377 |
+
const type = indicatorSelect.value;
|
| 5378 |
+
const isBbVisible = (type === 'bb' || type === 'both');
|
| 5379 |
+
|
| 5380 |
+
// Update global chart
|
| 5381 |
+
if (typeof bbUpperSeries !== 'undefined') {
|
| 5382 |
+
bbUpperSeries.applyOptions({ visible: isBbVisible });
|
| 5383 |
+
bbMiddleSeries.applyOptions({ visible: isBbVisible });
|
| 5384 |
+
bbLowerSeries.applyOptions({ visible: isBbVisible });
|
| 5385 |
+
}
|
| 5386 |
+
|
| 5387 |
+
// Update all Workspace panes
|
| 5388 |
+
if (window.Workspace && window.Workspace.panes) {
|
| 5389 |
+
window.Workspace.panes.forEach(pane => {
|
| 5390 |
+
if (pane.indicatorSeries) {
|
| 5391 |
+
if (pane.indicatorSeries.bbUpper) pane.indicatorSeries.bbUpper.applyOptions({ visible: isBbVisible });
|
| 5392 |
+
if (pane.indicatorSeries.bbMid) pane.indicatorSeries.bbMid.applyOptions({ visible: isBbVisible });
|
| 5393 |
+
if (pane.indicatorSeries.bbLower) pane.indicatorSeries.bbLower.applyOptions({ visible: isBbVisible });
|
| 5394 |
+
}
|
| 5395 |
+
});
|
| 5396 |
+
}
|
| 5397 |
+
};
|
| 5398 |
|
| 5399 |
// "PhΓ’n tΓch" button: toggle dashboard ON/OFF without reloading chart
|
| 5400 |
refreshBtn.onclick = () => {
|
|
|
|
| 5423 |
}
|
| 5424 |
};
|
| 5425 |
|
| 5426 |
+
refreshBtn.onclick = () => {
|
| 5427 |
+
const pane = window.Workspace && typeof Workspace.getActivePane === 'function'
|
| 5428 |
+
? Workspace.getActivePane()
|
| 5429 |
+
: null;
|
| 5430 |
+
if (!pane) return;
|
| 5431 |
+
const willOpen = !pane.analysisOpen;
|
| 5432 |
+
if (window.Workspace && Workspace.panes) {
|
| 5433 |
+
Workspace.panes.forEach((otherPane) => {
|
| 5434 |
+
if (otherPane !== pane) {
|
| 5435 |
+
setPaneAnalysisOpen(otherPane, false);
|
| 5436 |
+
renderPaneAnalysisUI(otherPane);
|
| 5437 |
+
}
|
| 5438 |
+
});
|
| 5439 |
+
}
|
| 5440 |
+
setPaneAnalysisOpen(pane, willOpen);
|
| 5441 |
+
renderPaneAnalysisUI(pane);
|
| 5442 |
+
if (willOpen && typeof pane.fetchAI === 'function') {
|
| 5443 |
+
pane.fetchAI({ force: !pane.lastAnalysis?.payload });
|
| 5444 |
+
}
|
| 5445 |
+
};
|
| 5446 |
+
|
| 5447 |
+
fitBtn.onclick = () => {
|
| 5448 |
+
fitChartWithOffset(chart);
|
| 5449 |
+
if (window.Workspace && window.Workspace.panes) {
|
| 5450 |
+
window.Workspace.panes.forEach(pane => {
|
| 5451 |
+
if (pane.chartInstance) {
|
| 5452 |
+
fitChartWithOffset(pane.chartInstance);
|
| 5453 |
+
}
|
| 5454 |
+
});
|
| 5455 |
+
}
|
| 5456 |
+
};
|
| 5457 |
|
| 5458 |
document.addEventListener('keydown', (e) => {
|
| 5459 |
const target = e.target;
|
|
|
|
| 5600 |
applyTheme(newTheme);
|
| 5601 |
};
|
| 5602 |
|
| 5603 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5604 |
+
MULTI-PANE WORKSPACE ENGINE
|
| 5605 |
+
ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 5606 |
+
const workspaceGrid = document.getElementById('workspaceGrid');
|
| 5607 |
+
const layoutSwitcher = document.getElementById('layoutSwitcher');
|
| 5608 |
+
|
| 5609 |
+
// Register pane-0 (already in DOM) into Workspace
|
| 5610 |
+
Workspace.init(workspaceGrid);
|
| 5611 |
+
const pane0 = Workspace.createPane('pane-0', 'XAUUSD', '1d');
|
| 5612 |
+
{
|
| 5613 |
+
const container = workspaceGrid.querySelector('.chart-pane[data-pane-id="pane-0"]');
|
| 5614 |
+
pane0.containerEl = container;
|
| 5615 |
+
pane0.chartEl = container.querySelector('.pane-chart');
|
| 5616 |
+
pane0.loaderEl = container.querySelector('.pane-loader');
|
| 5617 |
+
pane0.gaugesEl = container.querySelector('.pane-gauges');
|
| 5618 |
+
pane0.paneHeaderEl = container.querySelector('.pane-header-mini');
|
| 5619 |
+
pane0.priceEl = container.querySelector('.pane-price');
|
| 5620 |
+
pane0.analysisButtonEl = container.querySelector('.pane-analysis-btn');
|
| 5621 |
+
pane0.analysisOverlayEl = container.querySelector('.pane-analysis-overlay');
|
| 5622 |
+
// Bind existing chart instance to pane-0
|
| 5623 |
+
pane0.chartInstance = chart;
|
| 5624 |
+
pane0.candleSeries = candleSeries;
|
| 5625 |
+
pane0.forecastSeries = {
|
| 5626 |
+
candles: forecastCandleSeries,
|
| 5627 |
+
p50: p50Series,
|
| 5628 |
+
p10: p10Series,
|
| 5629 |
+
p90: p90Series,
|
| 5630 |
+
segments: forecastSegmentSeries,
|
| 5631 |
+
};
|
| 5632 |
+
pane0.indicatorSeries = { bbUpper: bbUpperSeries, bbMid: bbMiddleSeries, bbLower: bbLowerSeries, rsi: rsiSeries };
|
| 5633 |
+
pane0.horizon = Math.max(5, Math.min(300, parseInt(horizonInput.value, 10) || 10));
|
| 5634 |
+
bindPaneAnalysisButton(pane0);
|
| 5635 |
+
renderPaneAnalysisUI(pane0);
|
| 5636 |
+
if (pane0.gaugesEl && Workspace.layoutPreset === 1) {
|
| 5637 |
+
pane0.gaugesEl.innerHTML = '';
|
| 5638 |
+
pane0.gaugesEl.style.display = 'none';
|
| 5639 |
+
}
|
| 5640 |
+
container.addEventListener('click', () => Workspace.setActivePane('pane-0'));
|
| 5641 |
+
}
|
| 5642 |
+
|
| 5643 |
+
// Chart creation helper for new panes
|
| 5644 |
+
function createPaneChart(pane) {
|
| 5645 |
+
const isDark = document.body.classList.contains('dark-theme');
|
| 5646 |
+
const chartInstance = LightweightCharts.createChart(pane.chartEl, {
|
| 5647 |
+
layout: {
|
| 5648 |
+
background: { type: 'solid', color: 'transparent' },
|
| 5649 |
+
textColor: isDark ? 'rgba(100, 150, 200, 0.85)' : '#475569',
|
| 5650 |
+
fontSize: 10,
|
| 5651 |
+
fontFamily: "'Space Mono', 'Courier New', monospace",
|
| 5652 |
+
},
|
| 5653 |
+
grid: { vertLines: { visible: false }, horzLines: { visible: false } },
|
| 5654 |
+
rightPriceScale: {
|
| 5655 |
+
borderColor: 'rgba(40, 80, 140, 0.25)',
|
| 5656 |
+
autoScale: true,
|
| 5657 |
+
scaleMargins: { top: 0.08, bottom: 0.08 },
|
| 5658 |
+
},
|
| 5659 |
+
timeScale: {
|
| 5660 |
+
borderColor: 'rgba(40, 80, 140, 0.25)',
|
| 5661 |
+
timeVisible: true,
|
| 5662 |
+
secondsVisible: false,
|
| 5663 |
+
rightOffset: CHART_RIGHT_OFFSET,
|
| 5664 |
+
},
|
| 5665 |
+
crosshair: {
|
| 5666 |
+
mode: LightweightCharts.CrosshairMode.Normal,
|
| 5667 |
+
vertLine: { color: 'rgba(34, 211, 238, 0.35)', width: 1 },
|
| 5668 |
+
horzLine: { color: 'rgba(34, 211, 238, 0.35)', width: 1 },
|
| 5669 |
+
},
|
| 5670 |
+
watermark: {
|
| 5671 |
+
visible: true,
|
| 5672 |
+
fontSize: 32,
|
| 5673 |
+
horzAlign: 'center',
|
| 5674 |
+
vertAlign: 'center',
|
| 5675 |
+
color: isDark ? 'rgba(34, 211, 238, 0.06)' : 'rgba(15, 23, 42, 0.06)',
|
| 5676 |
+
text: pane.symbol,
|
| 5677 |
+
},
|
| 5678 |
+
handleScroll: true,
|
| 5679 |
+
handleScale: true,
|
| 5680 |
+
});
|
| 5681 |
+
|
| 5682 |
+
pane.chartInstance = chartInstance;
|
| 5683 |
+
pane.candleSeries = chartInstance.addCandlestickSeries({
|
| 5684 |
+
upColor: '#1dba8a', downColor: '#e05560',
|
| 5685 |
+
borderVisible: false, wickUpColor: '#1dba8a', wickDownColor: '#e05560',
|
| 5686 |
+
});
|
| 5687 |
+
pane.forecastSeries.candles = chartInstance.addCandlestickSeries(buildForecastCandleSeriesOptions());
|
| 5688 |
+
pane.forecastSeries.p50 = chartInstance.addLineSeries({ color: '#7dd3fc', lineWidth: 2, lineStyle: 0, priceLineVisible: false, lastValueVisible: false, visible: false });
|
| 5689 |
+
pane.forecastSeries.p10 = chartInstance.addLineSeries({ color: 'rgba(125,211,252,0.4)', lineWidth: 1, lineStyle: 2, priceLineVisible: false, lastValueVisible: false, visible: false });
|
| 5690 |
+
pane.forecastSeries.p90 = chartInstance.addLineSeries({ color: 'rgba(125,211,252,0.4)', lineWidth: 1, lineStyle: 2, priceLineVisible: false, lastValueVisible: false, visible: false });
|
| 5691 |
+
const isBbVisible = (document.getElementById('indicatorSelect') && (document.getElementById('indicatorSelect').value === 'bb' || document.getElementById('indicatorSelect').value === 'both'));
|
| 5692 |
+
pane.indicatorSeries.bbMid = chartInstance.addLineSeries({ color: 'rgba(255,255,255,0.2)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: isBbVisible });
|
| 5693 |
+
pane.indicatorSeries.bbUpper = chartInstance.addLineSeries({ color: 'rgba(34,211,238,0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: isBbVisible });
|
| 5694 |
+
pane.indicatorSeries.bbLower = chartInstance.addLineSeries({ color: 'rgba(34,211,238,0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: isBbVisible });
|
| 5695 |
+
|
| 5696 |
+
// ResizeObserver
|
| 5697 |
+
const paneRo = new ResizeObserver(() => {
|
| 5698 |
+
chartInstance.applyOptions({ width: pane.chartEl.clientWidth, height: pane.chartEl.clientHeight });
|
| 5699 |
+
});
|
| 5700 |
+
paneRo.observe(pane.chartEl);
|
| 5701 |
+
pane._resizeObserver = paneRo;
|
| 5702 |
+
|
| 5703 |
+
return chartInstance;
|
| 5704 |
+
}
|
| 5705 |
+
|
| 5706 |
+
// Load data for a specific pane
|
| 5707 |
+
async function loadPaneData(pane) {
|
| 5708 |
+
if (!pane || !pane.chartInstance) return;
|
| 5709 |
+
const { symbol, interval } = pane;
|
| 5710 |
+
|
| 5711 |
+
// Show loader
|
| 5712 |
+
if (pane.loaderEl) pane.loaderEl.classList.remove('hidden');
|
| 5713 |
+
renderPaneAnalysisUI(pane);
|
| 5714 |
+
|
| 5715 |
+
// Abort previous
|
| 5716 |
+
if (pane.fetchController) pane.fetchController.abort();
|
| 5717 |
+
resetPendingPaneAI(pane);
|
| 5718 |
+
pane.fetchController = new AbortController();
|
| 5719 |
+
const signal = pane.fetchController.signal;
|
| 5720 |
+
|
| 5721 |
+
const sLabel = symbolMap.get(symbol) || symbol;
|
| 5722 |
+
const tLabel = timeframeMap[interval] || interval;
|
| 5723 |
+
const isDark = document.body.classList.contains('dark-theme');
|
| 5724 |
+
|
| 5725 |
+
pane.chartInstance.applyOptions({
|
| 5726 |
+
watermark: {
|
| 5727 |
+
text: `${sLabel} | ${tLabel}`,
|
| 5728 |
+
color: isDark ? 'rgba(34, 211, 238, 0.15)' : 'rgba(15, 23, 42, 0.08)',
|
| 5729 |
+
fontSize: Workspace.layoutPreset === 1 ? 72 : 28,
|
| 5730 |
+
},
|
| 5731 |
+
});
|
| 5732 |
+
|
| 5733 |
+
try {
|
| 5734 |
+
const [histData, indData] = await Promise.all([
|
| 5735 |
+
DataCoordinator.fetchHistorical(symbol, interval, CHART_HISTORY_LIMIT, signal),
|
| 5736 |
+
DataCoordinator.fetchIndicators(symbol, interval, CHART_HISTORY_LIMIT, signal),
|
| 5737 |
+
]);
|
| 5738 |
+
|
| 5739 |
+
// Apply price format
|
| 5740 |
+
const pf = resolvePriceFormat(symbol, histData.data);
|
| 5741 |
+
pane.priceFormat = pf;
|
| 5742 |
+
const pfOpts = { priceFormat: { type: 'price', precision: pf.precision, minMove: pf.minMove } };
|
| 5743 |
+
pane.candleSeries.applyOptions(pfOpts);
|
| 5744 |
+
if (pane.forecastSeries.candles) pane.forecastSeries.candles.applyOptions(pfOpts);
|
| 5745 |
+
if (pane.indicatorSeries.bbUpper) pane.indicatorSeries.bbUpper.applyOptions(pfOpts);
|
| 5746 |
+
if (pane.indicatorSeries.bbMid) pane.indicatorSeries.bbMid.applyOptions(pfOpts);
|
| 5747 |
+
if (pane.indicatorSeries.bbLower) pane.indicatorSeries.bbLower.applyOptions(pfOpts);
|
| 5748 |
+
|
| 5749 |
+
if (histData.data.length > 0) {
|
| 5750 |
+
pane.candleSeries.setData(histData.data);
|
| 5751 |
+
pane.lastCandleData = histData.data[histData.data.length - 1];
|
| 5752 |
+
if (pane.paneId === 'pane-0') {
|
| 5753 |
+
lastCandleData = pane.lastCandleData;
|
| 5754 |
+
}
|
| 5755 |
+
}
|
| 5756 |
+
|
| 5757 |
+
// Indicators
|
| 5758 |
+
const series = (indData.indicators || {}).series || {};
|
| 5759 |
+
if (series.bb_upper) pane.indicatorSeries.bbUpper.setData(series.bb_upper);
|
| 5760 |
+
if (series.bb_mid) pane.indicatorSeries.bbMid.setData(series.bb_mid);
|
| 5761 |
+
if (series.bb_lower) pane.indicatorSeries.bbLower.setData(series.bb_lower);
|
| 5762 |
+
|
| 5763 |
+
fitChartWithOffset(pane.chartInstance);
|
| 5764 |
+
pane.chartContext = { symbol, interval };
|
| 5765 |
+
if (pane.paneId === 'pane-0') {
|
| 5766 |
+
currentPriceFormat = pane.priceFormat;
|
| 5767 |
+
activeChartContext = { symbol, interval };
|
| 5768 |
+
currentSymbol = symbol;
|
| 5769 |
+
currentInterval = interval;
|
| 5770 |
+
}
|
| 5771 |
+
|
| 5772 |
+
// Start AI fetch for this pane
|
| 5773 |
+
if (typeof pane.fetchAI === 'function') {
|
| 5774 |
+
pane.fetchAI({ force: true });
|
| 5775 |
+
}
|
| 5776 |
+
|
| 5777 |
+
// Update pane header with last price
|
| 5778 |
+
if (pane.lastCandleData && pane.priceEl) {
|
| 5779 |
+
const price = pane.lastCandleData.close;
|
| 5780 |
+
const precision = pane.priceFormat.precision;
|
| 5781 |
+
pane.priceEl.textContent = Number(price).toFixed(precision);
|
| 5782 |
+
}
|
| 5783 |
+
|
| 5784 |
+
} catch (e) {
|
| 5785 |
+
if (e.name === 'AbortError') return;
|
| 5786 |
+
console.error(`[Pane ${pane.paneId}] Data load error:`, e);
|
| 5787 |
+
pane.error = e.message;
|
| 5788 |
+
renderPaneAnalysisUI(pane);
|
| 5789 |
+
} finally {
|
| 5790 |
+
if (pane.loaderEl) pane.loaderEl.classList.add('hidden');
|
| 5791 |
+
}
|
| 5792 |
+
}
|
| 5793 |
+
|
| 5794 |
+
// Connect WS for a pane
|
| 5795 |
+
function connectPaneWS(pane) {
|
| 5796 |
+
StreamManager.subscribe(pane.paneId, pane.symbol, pane.interval, (data) => {
|
| 5797 |
+
if (!pane.lastCandleData || !data.price) return;
|
| 5798 |
+
if (shouldMutateRealtimeCandle(pane.symbol)) {
|
| 5799 |
+
const update = {
|
| 5800 |
+
time: pane.lastCandleData.time,
|
| 5801 |
+
open: pane.lastCandleData.open,
|
| 5802 |
+
high: Math.max(pane.lastCandleData.high, data.price),
|
| 5803 |
+
low: Math.min(pane.lastCandleData.low, data.price),
|
| 5804 |
+
close: data.price,
|
| 5805 |
+
};
|
| 5806 |
+
if (pane.candleSeries) pane.candleSeries.update(update);
|
| 5807 |
+
pane.lastCandleData = update;
|
| 5808 |
+
if (pane.paneId === 'pane-0') {
|
| 5809 |
+
lastCandleData = update;
|
| 5810 |
+
}
|
| 5811 |
+
}
|
| 5812 |
+
|
| 5813 |
+
// Update mini header price
|
| 5814 |
+
if (pane.priceEl) {
|
| 5815 |
+
pane.priceEl.textContent = Number(data.price).toFixed(pane.priceFormat.precision);
|
| 5816 |
+
}
|
| 5817 |
+
});
|
| 5818 |
+
}
|
| 5819 |
+
|
| 5820 |
+
// Destroy a pane (cleanup resources)
|
| 5821 |
+
function destroyPaneResources(pane) {
|
| 5822 |
+
clearPaneForecastVisuals(pane);
|
| 5823 |
+
StreamManager.unsubscribe(pane.paneId);
|
| 5824 |
+
if (pane.fetchController) { pane.fetchController.abort(); pane.fetchController = null; }
|
| 5825 |
+
if (pane._resizeObserver) { pane._resizeObserver.disconnect(); pane._resizeObserver = null; }
|
| 5826 |
+
if (pane.chartInstance && pane.paneId !== 'pane-0') {
|
| 5827 |
+
try { pane.chartInstance.remove(); } catch (_) {}
|
| 5828 |
+
pane.chartInstance = null;
|
| 5829 |
+
}
|
| 5830 |
+
}
|
| 5831 |
+
|
| 5832 |
+
// Switch layout handler
|
| 5833 |
+
async function switchLayout(preset, options = {}) {
|
| 5834 |
+
if (preset === Workspace.layoutPreset) return;
|
| 5835 |
+
const prevPreset = Workspace.layoutPreset;
|
| 5836 |
+
const restoredPanes = Array.isArray(options.panes) ? options.panes : null;
|
| 5837 |
+
|
| 5838 |
+
// Destroy extra panes (keep pane-0)
|
| 5839 |
+
for (const [id, pane] of Workspace.panes) {
|
| 5840 |
+
if (id !== 'pane-0') {
|
| 5841 |
+
destroyPaneResources(pane);
|
| 5842 |
+
}
|
| 5843 |
+
}
|
| 5844 |
+
|
| 5845 |
+
// Clear grid (keep pane-0 DOM)
|
| 5846 |
+
const pane0Container = workspaceGrid.querySelector('[data-pane-id="pane-0"]');
|
| 5847 |
+
workspaceGrid.innerHTML = '';
|
| 5848 |
+
if (pane0Container) workspaceGrid.appendChild(pane0Container);
|
| 5849 |
+
|
| 5850 |
+
// Clear panes map except pane-0
|
| 5851 |
+
for (const [id] of Workspace.panes) {
|
| 5852 |
+
if (id !== 'pane-0') Workspace.panes.delete(id);
|
| 5853 |
+
}
|
| 5854 |
+
|
| 5855 |
+
// Update layout
|
| 5856 |
+
Workspace.layoutPreset = preset;
|
| 5857 |
+
[1, 2, 4, 8].forEach(n => workspaceGrid.classList.remove(`layout-${n}`));
|
| 5858 |
+
workspaceGrid.classList.add(`layout-${preset}`);
|
| 5859 |
+
|
| 5860 |
+
// Update switcher buttons
|
| 5861 |
+
layoutSwitcher.querySelectorAll('button[data-layout]').forEach(btn => {
|
| 5862 |
+
btn.classList.toggle('active', parseInt(btn.dataset.layout) === preset);
|
| 5863 |
+
});
|
| 5864 |
+
const layoutMenuCurrent = document.getElementById('layoutMenuCurrent');
|
| 5865 |
+
const layoutMenuBtn = document.getElementById('layoutMenuBtn');
|
| 5866 |
+
if (layoutMenuCurrent) layoutMenuCurrent.textContent = String(preset);
|
| 5867 |
+
if (layoutMenuBtn) layoutMenuBtn.setAttribute('aria-expanded', 'false');
|
| 5868 |
+
layoutSwitcher.classList.remove('open');
|
| 5869 |
+
|
| 5870 |
+
// Update pane-0 watermark size
|
| 5871 |
+
const pane0 = Workspace.getPane('pane-0');
|
| 5872 |
+
if (pane0 && pane0.chartInstance) {
|
| 5873 |
+
pane0.chartInstance.applyOptions({
|
| 5874 |
+
watermark: { fontSize: preset === 1 ? 72 : 28 },
|
| 5875 |
+
});
|
| 5876 |
+
pane0.chartInstance.applyOptions({
|
| 5877 |
+
width: pane0.chartEl.clientWidth,
|
| 5878 |
+
height: pane0.chartEl.clientHeight,
|
| 5879 |
+
});
|
| 5880 |
+
}
|
| 5881 |
+
|
| 5882 |
+
// Show/hide global overlays based on mode
|
| 5883 |
+
const logoOverlay = document.querySelector('.chart-logo-overlay');
|
| 5884 |
+
if (logoOverlay) logoOverlay.style.display = preset === 1 ? '' : 'none';
|
| 5885 |
+
setGlobalStatusVisibility(preset === 1);
|
| 5886 |
+
setGlobalCompactGaugesVisibility(preset === 1);
|
| 5887 |
+
|
| 5888 |
+
const realStrengthSymbols = ['DXY', 'EURX', 'GBPX', 'CHFX', 'JPYX', 'CADX', 'AUDX', 'NZDX'];
|
| 5889 |
+
|
| 5890 |
+
// Create new panes for multi-chart mode
|
| 5891 |
+
if (preset > 1) {
|
| 5892 |
+
const defaultSymbols = preset === 8
|
| 5893 |
+
? realStrengthSymbols
|
| 5894 |
+
: ['EURUSD', 'GBPUSD', 'USDJPY', 'BTCUSD', 'XAGUSD', 'DXY', 'USDCHF'];
|
| 5895 |
+
const loadPromises = [];
|
| 5896 |
+
const restoredPane0 = restoredPanes && restoredPanes.length > 0
|
| 5897 |
+
? (restoredPanes.find((paneState) => paneState.id === 'pane-0') || restoredPanes[0])
|
| 5898 |
+
: null;
|
| 5899 |
+
|
| 5900 |
+
if (ws) {
|
| 5901 |
+
ws.close();
|
| 5902 |
+
ws = null;
|
| 5903 |
+
}
|
| 5904 |
+
|
| 5905 |
+
if (pane0) {
|
| 5906 |
+
const pane0Symbol = restoredPane0?.symbol || defaultSymbols[0] || pane0.symbol;
|
| 5907 |
+
const sharedInterval = restoredPane0?.interval || timeframeSelect.value || pane0.interval || '1d';
|
| 5908 |
+
pane0.symbol = pane0Symbol;
|
| 5909 |
+
pane0.interval = sharedInterval;
|
| 5910 |
+
pane0.horizon = Math.max(5, Math.min(300, parseInt(restoredPane0?.horizon, 10) || parseInt(horizonInput.value, 10) || 10));
|
| 5911 |
+
currentSymbol = pane0Symbol;
|
| 5912 |
+
currentInterval = sharedInterval;
|
| 5913 |
+
symbolSearch.value = pane0Symbol;
|
| 5914 |
+
if (pane0.paneHeaderEl) {
|
| 5915 |
+
const symEl = pane0.paneHeaderEl.querySelector('.pane-symbol');
|
| 5916 |
+
const intEl = pane0.paneHeaderEl.querySelector('.pane-interval');
|
| 5917 |
+
if (symEl) symEl.textContent = pane0Symbol;
|
| 5918 |
+
if (intEl) intEl.textContent = sharedInterval;
|
| 5919 |
+
}
|
| 5920 |
+
StreamManager.unsubscribe(pane0.paneId);
|
| 5921 |
+
if (pane0.candleSeries) pane0.candleSeries.setData([]);
|
| 5922 |
+
clearForecastVisuals();
|
| 5923 |
+
clearPaneForecastVisuals(pane0);
|
| 5924 |
+
pane0.lastCandleData = null;
|
| 5925 |
+
pane0.lastAnalysis = { payload: null, symbol: null, interval: null };
|
| 5926 |
+
pane0.forecastContext = { symbol: null, interval: null, ready: false };
|
| 5927 |
+
pane0.error = null;
|
| 5928 |
+
renderPaneAnalysisUI(pane0);
|
| 5929 |
+
loadPromises.push(loadPaneData(pane0).then(() => connectPaneWS(pane0)));
|
| 5930 |
+
}
|
| 5931 |
+
|
| 5932 |
+
for (let i = 1; i < preset; i++) {
|
| 5933 |
+
const paneId = `pane-${i}`;
|
| 5934 |
+
const restoredPaneState = restoredPanes?.find((paneState) => paneState.id === paneId) || null;
|
| 5935 |
+
const sym = restoredPaneState?.symbol || defaultSymbols[i % defaultSymbols.length];
|
| 5936 |
+
const paneInterval = restoredPaneState?.interval || timeframeSelect.value || '1d';
|
| 5937 |
+
const pane = Workspace.createPane(paneId, sym, paneInterval);
|
| 5938 |
+
|
| 5939 |
+
// Build DOM
|
| 5940 |
+
const container = document.createElement('div');
|
| 5941 |
+
container.className = 'chart-pane';
|
| 5942 |
+
container.dataset.paneId = paneId;
|
| 5943 |
+
container.innerHTML = `
|
| 5944 |
+
<div class="pane-header-mini">
|
| 5945 |
+
<span class="pane-symbol">${sym}</span>
|
| 5946 |
+
<span class="pane-sep">Β·</span>
|
| 5947 |
+
<span class="pane-interval">1d</span>
|
| 5948 |
+
<span class="pane-price">--</span>
|
| 5949 |
+
</div>
|
| 5950 |
+
<button class="pane-analysis-btn" type="button" data-pane-analysis="${paneId}" data-state="idle" aria-label="PhΓ’n tΓch ${sym}">
|
| 5951 |
+
<span class="dot"></span>
|
| 5952 |
+
<span>AI</span>
|
| 5953 |
+
</button>
|
| 5954 |
+
<div class="pane-chart" id="pane-chart-${paneId}"></div>
|
| 5955 |
+
<div class="pane-loader hidden"><div class="loader-ring"></div></div>
|
| 5956 |
+
<div class="pane-gauges"></div>
|
| 5957 |
+
<div class="pane-analysis-overlay"></div>
|
| 5958 |
+
`;
|
| 5959 |
+
|
| 5960 |
+
pane.containerEl = container;
|
| 5961 |
+
pane.chartEl = container.querySelector('.pane-chart');
|
| 5962 |
+
pane.loaderEl = container.querySelector('.pane-loader');
|
| 5963 |
+
pane.gaugesEl = container.querySelector('.pane-gauges');
|
| 5964 |
+
pane.paneHeaderEl = container.querySelector('.pane-header-mini');
|
| 5965 |
+
pane.priceEl = container.querySelector('.pane-price');
|
| 5966 |
+
pane.analysisButtonEl = container.querySelector('.pane-analysis-btn');
|
| 5967 |
+
pane.analysisOverlayEl = container.querySelector('.pane-analysis-overlay');
|
| 5968 |
+
pane.horizon = Math.max(5, Math.min(300, parseInt(restoredPaneState?.horizon, 10) || parseInt(horizonInput.value, 10) || 10));
|
| 5969 |
+
bindPaneAnalysisButton(pane);
|
| 5970 |
+
renderPaneAnalysisUI(pane);
|
| 5971 |
+
|
| 5972 |
+
container.addEventListener('click', () => {
|
| 5973 |
+
Workspace.setActivePane(paneId);
|
| 5974 |
+
syncToolbarToPane(paneId);
|
| 5975 |
+
});
|
| 5976 |
+
|
| 5977 |
+
workspaceGrid.appendChild(container);
|
| 5978 |
+
|
| 5979 |
+
// Create chart instance
|
| 5980 |
+
createPaneChart(pane);
|
| 5981 |
+
|
| 5982 |
+
// Load data + WS
|
| 5983 |
+
loadPromises.push(
|
| 5984 |
+
loadPaneData(pane).then(() => connectPaneWS(pane))
|
| 5985 |
+
);
|
| 5986 |
+
}
|
| 5987 |
+
|
| 5988 |
+
// Load all panes in parallel (throttled by DataCoordinator)
|
| 5989 |
+
await Promise.allSettled(loadPromises);
|
| 5990 |
+
} else if (pane0) {
|
| 5991 |
+
StreamManager.unsubscribe(pane0.paneId);
|
| 5992 |
+
clearPaneForecastVisuals(pane0);
|
| 5993 |
+
if (pane0.gaugesEl) {
|
| 5994 |
+
pane0.gaugesEl.innerHTML = '';
|
| 5995 |
+
pane0.gaugesEl.style.display = 'none';
|
| 5996 |
+
}
|
| 5997 |
+
currentSymbol = pane0.symbol;
|
| 5998 |
+
currentInterval = pane0.interval;
|
| 5999 |
+
symbolSearch.value = pane0.symbol;
|
| 6000 |
+
timeframeSelect.value = pane0.interval;
|
| 6001 |
+
connectWS(pane0.symbol, pane0.interval);
|
| 6002 |
+
}
|
| 6003 |
+
|
| 6004 |
+
// Ensure active pane is valid
|
| 6005 |
+
if (!Workspace.panes.has(Workspace.activePaneId)) {
|
| 6006 |
+
Workspace.setActivePane('pane-0');
|
| 6007 |
+
}
|
| 6008 |
+
Workspace.setActivePane(Workspace.activePaneId);
|
| 6009 |
+
if (pane0?.gaugesEl) {
|
| 6010 |
+
pane0.gaugesEl.style.display = preset === 1 ? 'none' : '';
|
| 6011 |
+
if (preset === 1) {
|
| 6012 |
+
pane0.gaugesEl.innerHTML = '';
|
| 6013 |
+
}
|
| 6014 |
+
}
|
| 6015 |
+
|
| 6016 |
+
// Resize pane-0 chart after layout change
|
| 6017 |
+
requestAnimationFrame(() => {
|
| 6018 |
+
if (pane0 && pane0.chartInstance) {
|
| 6019 |
+
pane0.chartInstance.applyOptions({
|
| 6020 |
+
width: pane0.chartEl.clientWidth,
|
| 6021 |
+
height: pane0.chartEl.clientHeight,
|
| 6022 |
+
});
|
| 6023 |
+
}
|
| 6024 |
+
});
|
| 6025 |
+
|
| 6026 |
+
Workspace.save();
|
| 6027 |
+
}
|
| 6028 |
+
|
| 6029 |
+
// Sync toolbar controls to the active pane's state
|
| 6030 |
+
function syncToolbarToPane(paneId) {
|
| 6031 |
+
const pane = Workspace.getPane(paneId);
|
| 6032 |
+
if (!pane) return;
|
| 6033 |
+
|
| 6034 |
+
// Update toolbar to reflect pane state
|
| 6035 |
+
symbolSearch.value = pane.symbol;
|
| 6036 |
+
// Don't trigger change events β just update display
|
| 6037 |
+
const tfOptions = timeframeSelect.options;
|
| 6038 |
+
for (let i = 0; i < tfOptions.length; i++) {
|
| 6039 |
+
if (tfOptions[i].value === pane.interval) {
|
| 6040 |
+
timeframeSelect.selectedIndex = i;
|
| 6041 |
+
break;
|
| 6042 |
+
}
|
| 6043 |
+
}
|
| 6044 |
+
|
| 6045 |
+
// Update active pane globals for backward compat
|
| 6046 |
+
currentSymbol = pane.symbol;
|
| 6047 |
+
currentInterval = pane.interval;
|
| 6048 |
+
}
|
| 6049 |
+
|
| 6050 |
+
// Apply symbol to active pane (for multi-pane mode)
|
| 6051 |
+
function applySymbolToActivePane(symbol) {
|
| 6052 |
+
const pane = Workspace.getActivePane();
|
| 6053 |
+
if (!pane) return;
|
| 6054 |
+
|
| 6055 |
+
if (Workspace.layoutPreset === 1) {
|
| 6056 |
+
// Single pane mode β use original switchSymbol
|
| 6057 |
+
switchSymbol(symbol);
|
| 6058 |
+
return;
|
| 6059 |
+
}
|
| 6060 |
+
|
| 6061 |
+
// Multi-pane mode: update the active pane
|
| 6062 |
+
pane.symbol = symbol;
|
| 6063 |
+
currentSymbol = symbol;
|
| 6064 |
+
symbolSearch.value = symbol;
|
| 6065 |
+
|
| 6066 |
+
// Update pane header
|
| 6067 |
+
if (pane.paneHeaderEl) {
|
| 6068 |
+
const symEl = pane.paneHeaderEl.querySelector('.pane-symbol');
|
| 6069 |
+
if (symEl) symEl.textContent = symbol;
|
| 6070 |
+
}
|
| 6071 |
+
|
| 6072 |
+
// Disconnect old WS, clear data
|
| 6073 |
+
resetPendingPaneAI(pane);
|
| 6074 |
+
StreamManager.unsubscribe(pane.paneId);
|
| 6075 |
+
if (pane.candleSeries) pane.candleSeries.setData([]);
|
| 6076 |
+
if (typeof clearPaneForecastVisuals === 'function') {
|
| 6077 |
+
clearPaneForecastVisuals(pane);
|
| 6078 |
+
} else {
|
| 6079 |
+
if (pane.forecastSeries?.candles) pane.forecastSeries.candles.setData([]);
|
| 6080 |
+
if (pane.forecastSeries?.p50) pane.forecastSeries.p50.setData([]);
|
| 6081 |
+
if (pane.forecastSeries?.p10) pane.forecastSeries.p10.setData([]);
|
| 6082 |
+
if (pane.forecastSeries?.p90) pane.forecastSeries.p90.setData([]);
|
| 6083 |
+
}
|
| 6084 |
+
pane.lastCandleData = null;
|
| 6085 |
+
pane.lastAnalysis = { payload: null, symbol: null, interval: null };
|
| 6086 |
+
pane.error = null;
|
| 6087 |
+
renderPaneAnalysisUI(pane);
|
| 6088 |
+
|
| 6089 |
+
// Reload
|
| 6090 |
+
loadPaneData(pane).then(() => connectPaneWS(pane));
|
| 6091 |
+
Workspace.save();
|
| 6092 |
+
}
|
| 6093 |
+
|
| 6094 |
+
// Wire layout switcher buttons
|
| 6095 |
+
if (layoutSwitcher) {
|
| 6096 |
+
layoutSwitcher.addEventListener('click', (e) => {
|
| 6097 |
+
const menuBtn = e.target.closest('#layoutMenuBtn');
|
| 6098 |
+
if (menuBtn) {
|
| 6099 |
+
e.stopPropagation();
|
| 6100 |
+
const willOpen = !layoutSwitcher.classList.contains('open');
|
| 6101 |
+
layoutSwitcher.classList.toggle('open', willOpen);
|
| 6102 |
+
menuBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
| 6103 |
+
return;
|
| 6104 |
+
}
|
| 6105 |
+
const btn = e.target.closest('button[data-layout]');
|
| 6106 |
+
if (!btn) return;
|
| 6107 |
+
e.stopPropagation();
|
| 6108 |
+
const preset = parseInt(btn.dataset.layout);
|
| 6109 |
+
if (!isNaN(preset)) switchLayout(preset);
|
| 6110 |
+
});
|
| 6111 |
+
}
|
| 6112 |
+
|
| 6113 |
+
document.addEventListener('click', (event) => {
|
| 6114 |
+
if (!layoutSwitcher || layoutSwitcher.contains(event.target)) return;
|
| 6115 |
+
layoutSwitcher.classList.remove('open');
|
| 6116 |
+
const layoutMenuBtn = document.getElementById('layoutMenuBtn');
|
| 6117 |
+
if (layoutMenuBtn) layoutMenuBtn.setAttribute('aria-expanded', 'false');
|
| 6118 |
+
});
|
| 6119 |
+
|
| 6120 |
+
// Override explorer symbol selection for multi-pane
|
| 6121 |
+
window._originalExplorerSelectSymbol = window.explorerSelectSymbol;
|
| 6122 |
+
window.explorerSelectSymbol = function(sym) {
|
| 6123 |
+
if (Workspace.layoutPreset > 1) {
|
| 6124 |
+
applySymbolToActivePane(sym);
|
| 6125 |
+
closeExplorer();
|
| 6126 |
+
} else {
|
| 6127 |
+
switchSymbol(sym);
|
| 6128 |
+
closeExplorer();
|
| 6129 |
+
}
|
| 6130 |
+
};
|
| 6131 |
+
|
| 6132 |
+
// Wire Workspace active pane change callback
|
| 6133 |
+
Workspace._onActivePaneChange = (paneId) => {
|
| 6134 |
+
syncToolbarToPane(paneId);
|
| 6135 |
+
};
|
| 6136 |
+
|
| 6137 |
+
async function refreshWorkspacePanes() {
|
| 6138 |
+
if (!window.Workspace || !Workspace.panes || Workspace.layoutPreset <= 1) {
|
| 6139 |
+
await refreshChart();
|
| 6140 |
+
return;
|
| 6141 |
+
}
|
| 6142 |
+
|
| 6143 |
+
const refreshTasks = [];
|
| 6144 |
+
Workspace.panes.forEach((pane) => {
|
| 6145 |
+
refreshTasks.push(
|
| 6146 |
+
loadPaneData(pane).then(() => connectPaneWS(pane))
|
| 6147 |
+
);
|
| 6148 |
+
});
|
| 6149 |
+
|
| 6150 |
+
await Promise.allSettled(refreshTasks);
|
| 6151 |
+
if (Workspace.activePaneId && Workspace.panes.has(Workspace.activePaneId)) {
|
| 6152 |
+
syncToolbarToPane(Workspace.activePaneId);
|
| 6153 |
+
}
|
| 6154 |
+
}
|
| 6155 |
+
|
| 6156 |
+
// Add click handler to pane-0 for multi-pane mode
|
| 6157 |
+
{
|
| 6158 |
+
const p0Container = workspaceGrid.querySelector('[data-pane-id="pane-0"]');
|
| 6159 |
+
if (p0Container) {
|
| 6160 |
+
p0Container.addEventListener('click', () => {
|
| 6161 |
+
Workspace.setActivePane('pane-0');
|
| 6162 |
+
syncToolbarToPane('pane-0');
|
| 6163 |
+
});
|
| 6164 |
+
}
|
| 6165 |
+
}
|
| 6166 |
+
|
| 6167 |
/* ββ Bootstrap ββ */
|
| 6168 |
(async () => {
|
| 6169 |
// Restore theme preference (Default: light)
|
|
|
|
| 6177 |
// Start polling
|
| 6178 |
setInterval(refreshMarketStatus, 60000); // 1m
|
| 6179 |
|
| 6180 |
+
const restoredWorkspace = Workspace.restore();
|
| 6181 |
+
if (
|
| 6182 |
+
restoredWorkspace?.layoutPreset === 8 &&
|
| 6183 |
+
Array.isArray(restoredWorkspace.panes) &&
|
| 6184 |
+
restoredWorkspace.panes.some((pane) => pane?.symbol === 'USDX') &&
|
| 6185 |
+
!restoredWorkspace.panes.some((pane) => pane?.symbol === 'DXY')
|
| 6186 |
+
) {
|
| 6187 |
+
const symbols = new Set(restoredWorkspace.panes.map((pane) => pane?.symbol));
|
| 6188 |
+
const usdxStrengthLayout = ['USDX', 'EURX', 'GBPX', 'CHFX', 'JPYX', 'CADX', 'AUDX', 'NZDX'];
|
| 6189 |
+
if (usdxStrengthLayout.every((symbol) => symbols.has(symbol))) {
|
| 6190 |
+
restoredWorkspace.panes = restoredWorkspace.panes.map((pane) => (
|
| 6191 |
+
pane?.symbol === 'USDX'
|
| 6192 |
+
? { ...pane, symbol: 'DXY' }
|
| 6193 |
+
: pane
|
| 6194 |
+
));
|
| 6195 |
+
}
|
| 6196 |
+
}
|
| 6197 |
+
const restoredPane0State = restoredWorkspace?.panes?.find((pane) => pane.id === 'pane-0')
|
| 6198 |
+
|| restoredWorkspace?.panes?.[0]
|
| 6199 |
+
|| null;
|
| 6200 |
+
|
| 6201 |
+
if (restoredPane0State?.interval) {
|
| 6202 |
+
timeframeSelect.value = restoredPane0State.interval;
|
| 6203 |
+
}
|
| 6204 |
+
if (restoredPane0State?.horizon) {
|
| 6205 |
+
horizonInput.value = String(
|
| 6206 |
+
Math.max(5, Math.min(300, parseInt(restoredPane0State.horizon, 10) || 10))
|
| 6207 |
+
);
|
| 6208 |
+
}
|
| 6209 |
+
if (
|
| 6210 |
+
restoredPane0State?.indicator &&
|
| 6211 |
+
Array.from(indicatorSelect.options || []).some((option) => option.value === restoredPane0State.indicator)
|
| 6212 |
+
) {
|
| 6213 |
+
indicatorSelect.value = restoredPane0State.indicator;
|
| 6214 |
+
}
|
| 6215 |
+
|
| 6216 |
+
if (restoredWorkspace?.layoutPreset > 1) {
|
| 6217 |
+
await switchLayout(restoredWorkspace.layoutPreset, { panes: restoredWorkspace.panes || [] });
|
| 6218 |
+
if (restoredWorkspace.activePaneId && Workspace.panes.has(restoredWorkspace.activePaneId)) {
|
| 6219 |
+
Workspace.setActivePane(restoredWorkspace.activePaneId);
|
| 6220 |
+
syncToolbarToPane(restoredWorkspace.activePaneId);
|
| 6221 |
+
}
|
| 6222 |
+
} else if (restoredPane0State?.symbol) {
|
| 6223 |
+
await switchSymbol(restoredPane0State.symbol);
|
| 6224 |
+
} else {
|
| 6225 |
+
await switchSymbol('XAUUSD');
|
| 6226 |
+
}
|
| 6227 |
|
| 6228 |
+
if (typeof indicatorSelect.onchange === 'function') {
|
| 6229 |
+
indicatorSelect.onchange();
|
| 6230 |
+
}
|
| 6231 |
|
| 6232 |
// Setup auto-refresh (P1-10)
|
| 6233 |
let autoRefreshTimer = null;
|
| 6234 |
function scheduleAutoRefresh() {
|
| 6235 |
if (autoRefreshTimer) clearTimeout(autoRefreshTimer);
|
| 6236 |
|
| 6237 |
+
// Interval logic: intraday refresh nhanh hΖ‘n, daily/weekly refresh chαΊm hΖ‘n Δα» trΓ‘nh tαΊ£i thα»«a.
|
| 6238 |
const intv = timeframeSelect.value;
|
| 6239 |
+
let delay = 900000; // 15m default for 1d
|
| 6240 |
if (intv === '1m' || intv === '5m') delay = 60000;
|
| 6241 |
else if (intv === '15m' || intv === '30m') delay = 180000;
|
| 6242 |
else if (intv === '1h' || intv === '4h') delay = 600000;
|
| 6243 |
+
else if (intv === '1w') delay = 1800000;
|
| 6244 |
|
| 6245 |
autoRefreshTimer = setTimeout(async () => {
|
| 6246 |
if (!document.hidden) {
|
| 6247 |
console.log('[AutoRefresh] Triggering...');
|
| 6248 |
+
if (window.Workspace && Workspace.layoutPreset > 1) {
|
| 6249 |
+
await refreshWorkspacePanes();
|
| 6250 |
+
} else {
|
| 6251 |
+
await refreshChart();
|
| 6252 |
+
}
|
| 6253 |
}
|
| 6254 |
scheduleAutoRefresh();
|
| 6255 |
}, delay);
|
| 6256 |
}
|
| 6257 |
scheduleAutoRefresh();
|
| 6258 |
})();
|
| 6259 |
+
|
| 6260 |
+
function renderPaneCompactGauges(pane) {
|
| 6261 |
+
if (Workspace.layoutPreset === 1 && pane?.paneId === 'pane-0') {
|
| 6262 |
+
if (pane?.gaugesEl) {
|
| 6263 |
+
pane.gaugesEl.innerHTML = '';
|
| 6264 |
+
pane.gaugesEl.style.display = 'none';
|
| 6265 |
+
}
|
| 6266 |
+
return;
|
| 6267 |
+
}
|
| 6268 |
+
const payload = pane?.lastAnalysis?.payload;
|
| 6269 |
+
if (!pane?.gaugesEl || !payload?.analysis) {
|
| 6270 |
+
if (pane?.gaugesEl) pane.gaugesEl.innerHTML = '';
|
| 6271 |
+
return;
|
| 6272 |
+
}
|
| 6273 |
+
pane.gaugesEl.style.display = '';
|
| 6274 |
+
|
| 6275 |
+
const a = payload.analysis;
|
| 6276 |
+
const dashboard = a.dashboard || {};
|
| 6277 |
+
const technical = dashboard.technical || a.technicals || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 };
|
| 6278 |
+
const ai = dashboard.ai || a.ai_gauge || { gauge: 50, signal: '--' };
|
| 6279 |
+
const summary = dashboard.summary || a.summary || { gauge: 50, signal: '--' };
|
| 6280 |
+
const comboActive = Boolean(summary.agreement || (Math.abs((technical.gauge ?? 50) - 50) > 6 && Math.abs((ai.gauge ?? 50) - 50) > 6 && Math.sign((technical.gauge ?? 50) - 50) === Math.sign((ai.gauge ?? 50) - 50)));
|
| 6281 |
+
|
| 6282 |
+
pane.gaugesEl.innerHTML = `
|
| 6283 |
+
<div class="compact-gauge-card" style="cursor:pointer; --gauge-delay: 0s">
|
| 6284 |
+
<div class="compact-gauge-title">Kα»Ή thuαΊt</div>
|
| 6285 |
+
<div class="compact-gauge-svg-wrap">${buildGaugeSvg(gaugeToRawScore(technical.gauge), 80, 50, false)}</div>
|
| 6286 |
+
<div class="compact-gauge-signal ${getSignalClass(technical.signal)}">${technical.signal}</div>
|
| 6287 |
+
</div>
|
| 6288 |
+
<div class="compact-gauge-card" style="cursor:pointer; --gauge-delay: 0.08s">
|
| 6289 |
+
<div class="compact-gauge-title">Dα»± bΓ‘o AI</div>
|
| 6290 |
+
<div class="compact-gauge-svg-wrap">${buildGaugeSvg(gaugeToRawScore(ai.gauge), 80, 50, false)}</div>
|
| 6291 |
+
<div class="compact-gauge-signal ${getSignalClass(ai.signal)}">${ai.signal}</div>
|
| 6292 |
+
</div>
|
| 6293 |
+
<div class="compact-gauge-card hero ${comboActive ? 'combo-strong' : ''}" style="cursor:pointer; --gauge-delay: 0.16s">
|
| 6294 |
+
<div class="compact-gauge-title">Tα»ng kαΊΏt</div>
|
| 6295 |
+
<div class="compact-gauge-svg-wrap">${buildGaugeSvg(gaugeToRawScore(summary.gauge), 80, 50, false)}</div>
|
| 6296 |
+
<div class="compact-gauge-signal ${getSignalClass(summary.signal)}">${summary.signal}</div>
|
| 6297 |
+
</div>
|
| 6298 |
+
`;
|
| 6299 |
+
|
| 6300 |
+
pane.gaugesEl.querySelectorAll('.compact-gauge-card').forEach((card) => {
|
| 6301 |
+
card.addEventListener('click', (event) => {
|
| 6302 |
+
event.stopPropagation();
|
| 6303 |
+
if (pane.analysisButtonEl) pane.analysisButtonEl.click();
|
| 6304 |
+
});
|
| 6305 |
+
});
|
| 6306 |
+
}
|
| 6307 |
+
|
| 6308 |
+
function paneActCls(act) {
|
| 6309 |
+
return act === 'Mua' ? 'dt-act-buy' : act === 'BΓ‘n' ? 'dt-act-sell' : 'dt-act-neut';
|
| 6310 |
+
}
|
| 6311 |
+
|
| 6312 |
+
buildPaneAnalysisMarkup = function buildPaneAnalysisMarkupOverride(pane) {
|
| 6313 |
+
const payload = pane?.lastAnalysis?.payload;
|
| 6314 |
+
if (pane?.analysisFetchController && !payload) {
|
| 6315 |
+
return `<div class="pane-analysis-loading">AI Δang phΓ’n tΓch ${pane.symbol} ${pane.interval}...</div>`;
|
| 6316 |
+
}
|
| 6317 |
+
if (!payload?.analysis) {
|
| 6318 |
+
return `<div class="pane-analysis-empty">ChΖ°a cΓ³ dα»― liα»u phΓ’n tΓch cho ${pane?.symbol || '--'}.</div>`;
|
| 6319 |
+
}
|
| 6320 |
+
|
| 6321 |
+
const a = payload.analysis;
|
| 6322 |
+
if (!a.oscillators && !a.moving_averages) {
|
| 6323 |
+
return `<div class="pane-analysis-loading">Δang tΓnh toΓ‘n phΓ’n tΓch kα»Ή thuαΊt cho ${pane.symbol}...</div>`;
|
| 6324 |
+
}
|
| 6325 |
+
|
| 6326 |
+
const osc = a.oscillators || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
|
| 6327 |
+
const ma = a.moving_averages || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
|
| 6328 |
+
const technicals = a.technicals || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 };
|
| 6329 |
+
const aiGauge = a.ai_gauge || { gauge: 50, signal: '--', certainty: 0, path_consistency: 0 };
|
| 6330 |
+
const summary = a.summary || { signal: '--', gauge: 50 };
|
| 6331 |
+
const dashboard = a.dashboard || {};
|
| 6332 |
+
const pivots = (a.pivot_points || {}).data || [];
|
| 6333 |
+
const comboActive = Boolean(summary.agreement || (Math.abs((technicals.gauge ?? 50) - 50) > 6 && Math.abs((aiGauge.gauge ?? 50) - 50) > 6 && Math.sign((technicals.gauge ?? 50) - 50) === Math.sign((aiGauge.gauge ?? 50) - 50)));
|
| 6334 |
+
const forecastRows = payload.forecast || [];
|
| 6335 |
+
const lastClose = payload.last_close || 0;
|
| 6336 |
+
const aiCurrentPrice = dashboard.ai?.current_price ?? lastClose;
|
| 6337 |
+
const forecastEnd = dashboard.ai?.forecast_price ?? (forecastRows.length > 1 ? (forecastRows[forecastRows.length - 1]?.p50 ?? lastClose) : lastClose);
|
| 6338 |
+
const forecastPctChange = dashboard.ai?.forecast_return_pct ?? (lastClose > 0 ? ((forecastEnd - lastClose) / lastClose) * 100 : 0);
|
| 6339 |
+
const oscRows = osc.data.map(d => `<tr><td>${d.name}</td><td class="dt-val">${d.value !== null ? d.value : 'β'}</td><td class="dt-act ${paneActCls(d.action)}">${d.action}</td></tr>`).join('');
|
| 6340 |
+
const maRows = ma.data.map(d => `<tr><td>${d.name}</td><td class="dt-val">${d.value !== null ? d.value : 'β'}</td><td class="dt-act ${paneActCls(d.action)}">${d.action}</td></tr>`).join('');
|
| 6341 |
+
const pivotRows = pivots.map(p => `<tr><td>${p.level}</td><td>${p.classic ?? 'β'}</td><td>${p.fibonacci ?? 'β'}</td><td>${p.camarilla ?? 'β'}</td><td>${p.woodie ?? 'β'}</td><td>${p.dm ?? 'β'}</td></tr>`).join('');
|
| 6342 |
+
const sLabel = symbolMap.get(pane.symbol) || pane.symbol;
|
| 6343 |
+
const tLabel = timeframeMap[pane.interval] || pane.interval;
|
| 6344 |
+
const paneFormatPrice = (value) => {
|
| 6345 |
+
if (value === null || value === undefined || Number.isNaN(Number(value))) return '--';
|
| 6346 |
+
const precision = Math.max(0, Math.min(8, Number(pane.priceFormat?.precision ?? 2)));
|
| 6347 |
+
return Number(value).toLocaleString('en-US', {
|
| 6348 |
+
minimumFractionDigits: precision,
|
| 6349 |
+
maximumFractionDigits: precision,
|
| 6350 |
+
});
|
| 6351 |
+
};
|
| 6352 |
+
|
| 6353 |
+
return `
|
| 6354 |
+
<div class="pane-analysis-sheet">
|
| 6355 |
+
<div class="pane-analysis-title">
|
| 6356 |
+
<span>${sLabel} Β· ${tLabel}</span>
|
| 6357 |
+
<div class="pane-analysis-meta">
|
| 6358 |
+
<span class="pane-analysis-pill">${payload.source || 'N/A'}</span>
|
| 6359 |
+
<span class="pane-analysis-pill">${payload.verdict || summary.signal || 'NEUTRAL'}</span>
|
| 6360 |
+
</div>
|
| 6361 |
+
</div>
|
| 6362 |
+
<div class="dash-gauges-hero pane-dash-gauges ${comboActive ? 'combo-active' : ''}">
|
| 6363 |
+
<div class="gauge-hero-card" style="--gauge-delay: 0s">
|
| 6364 |
+
<div class="gauge-hero-title">PHΓN TΓCH Kα»Έ THUαΊ¬T</div>
|
| 6365 |
+
<div class="gauge-hero-svg-wrap">${buildGaugeSvg(gaugeToRawScore(technicals.gauge), 220, 150, true)}</div>
|
| 6366 |
+
<div class="gauge-hero-signal ${getSignalClass(technicals.signal)}">${technicals.signal}</div>
|
| 6367 |
+
<div class="gauge-hero-counts">
|
| 6368 |
+
<span><span class="ghc-label">BΓ‘n</span><span class="ghc-value">${technicals.sell}</span></span>
|
| 6369 |
+
<span><span class="ghc-label">Trung lαΊp</span><span class="ghc-value">${technicals.neutral}</span></span>
|
| 6370 |
+
<span><span class="ghc-label">Mua</span><span class="ghc-value">${technicals.buy}</span></span>
|
| 6371 |
+
</div>
|
| 6372 |
+
</div>
|
| 6373 |
+
<div class="gauge-hero-card" style="--gauge-delay: 0.08s">
|
| 6374 |
+
<div class="gauge-hero-title">Dα»° BΓO AI</div>
|
| 6375 |
+
<div class="gauge-hero-svg-wrap">${buildGaugeSvg(gaugeToRawScore(aiGauge.gauge), 220, 150, true)}</div>
|
| 6376 |
+
<div class="gauge-hero-ai-details">
|
| 6377 |
+
<div class="gh-ai-row"><span class="gh-ai-label">Hiα»n tαΊ‘i:</span><span class="gh-ai-val">${paneFormatPrice(aiCurrentPrice)}</span></div>
|
| 6378 |
+
<div class="gh-ai-row"><span class="gh-ai-label">Dα»± kiαΊΏn:</span><span class="gh-ai-val">${paneFormatPrice(forecastEnd)}</span></div>
|
| 6379 |
+
<div class="gh-ai-row"><span class="gh-ai-label">BiαΊΏn Δα»ng:</span><span class="gh-ai-val ${forecastPctChange >= 0 ? 'up' : 'down'}">${forecastPctChange >= 0 ? 'β' : 'β'} ${Math.abs(forecastPctChange).toFixed(2)}%</span></div>
|
| 6380 |
+
<div class="gh-ai-row"><span class="gh-ai-label">Δα» chαΊ―c chαΊ―n:</span><span class="gh-ai-val">${Number(aiGauge.certainty ?? 0).toFixed(1)}%</span></div>
|
| 6381 |
+
<div class="gh-ai-row"><span class="gh-ai-label">Δα» α»n Δα»nh:</span><span class="gh-ai-val">${Number(aiGauge.path_consistency ?? 0).toFixed(1)}%</span></div>
|
| 6382 |
+
</div>
|
| 6383 |
+
<div class="gauge-hero-signal ${getSignalClass(aiGauge.signal)}">${aiGauge.signal}</div>
|
| 6384 |
+
</div>
|
| 6385 |
+
<div class="gauge-hero-card hero-total summary-derived ${comboActive ? 'combo-strong' : ''}" style="--gauge-delay: 0.16s">
|
| 6386 |
+
<div class="gauge-hero-title title-total">β‘ Tα»NG KαΊΎT</div>
|
| 6387 |
+
<div class="gauge-hero-svg-wrap">${buildGaugeSvg(gaugeToRawScore(summary.gauge), 220, 150, true)}</div>
|
| 6388 |
+
<div class="gauge-hero-signal signal-total ${getSignalClass(summary.signal)}">${summary.signal}</div>
|
| 6389 |
+
</div>
|
| 6390 |
+
</div>
|
| 6391 |
+
<div class="dash-tables-row pane-dash-tables">
|
| 6392 |
+
<div class="dash-col">
|
| 6393 |
+
<div class="dc-header">Chα» bΓ‘o Kα»Ή thuαΊt</div>
|
| 6394 |
+
<div class="dash-table-wrap">
|
| 6395 |
+
<table class="dt"><tbody>${oscRows}</tbody></table>
|
| 6396 |
+
</div>
|
| 6397 |
+
</div>
|
| 6398 |
+
<div class="dash-col">
|
| 6399 |
+
<div class="dc-header">Trung bình trượt</div>
|
| 6400 |
+
<div class="dash-table-wrap">
|
| 6401 |
+
<table class="dt"><tbody>${maRows}</tbody></table>
|
| 6402 |
+
</div>
|
| 6403 |
+
</div>
|
| 6404 |
+
<div class="dash-col col-pivots">
|
| 6405 |
+
<div class="dc-header">Δiα»m xoay</div>
|
| 6406 |
+
<div class="dash-table-wrap">
|
| 6407 |
+
<table class="pivot-table">
|
| 6408 |
+
<thead><tr><th>Mα»©c</th><th>CL</th><th>FB</th><th>CM</th><th>WD</th><th>DM</th></tr></thead>
|
| 6409 |
+
<tbody>${pivotRows}</tbody>
|
| 6410 |
+
</table>
|
| 6411 |
+
</div>
|
| 6412 |
+
</div>
|
| 6413 |
+
</div>
|
| 6414 |
+
<div class="summary-disclaimer">
|
| 6415 |
+
<strong>β CαΊ£nh bΓ‘o</strong> β ThΓ΄ng tin phΓ’n tΓch kα»Ή thuαΊt nΓ y khΓ΄ng phαΊ£i lα»i khuyΓͺn ΔαΊ§u tΖ°. HΓ£y luΓ΄n quαΊ£n lΓ½ rα»§i ro.
|
| 6416 |
+
</div>
|
| 6417 |
+
</div>
|
| 6418 |
+
`;
|
| 6419 |
+
};
|
| 6420 |
+
|
| 6421 |
+
renderPaneAnalysisUI = function renderPaneAnalysisUIOverride(pane) {
|
| 6422 |
+
if (!pane) return;
|
| 6423 |
+
if (pane.analysisButtonEl) {
|
| 6424 |
+
let state = 'idle';
|
| 6425 |
+
if (pane.analysisFetchController) state = 'loading';
|
| 6426 |
+
else if (pane.lastAnalysis && pane.lastAnalysis.payload) state = 'ready';
|
| 6427 |
+
else if (pane.error) state = 'error';
|
| 6428 |
+
pane.analysisButtonEl.dataset.state = state;
|
| 6429 |
+
pane.analysisButtonEl.setAttribute('aria-label', `PhΓ’n tΓch ${pane.symbol} ${pane.interval}`);
|
| 6430 |
+
}
|
| 6431 |
+
renderPaneCompactGauges(pane);
|
| 6432 |
+
if (pane.analysisOverlayEl) {
|
| 6433 |
+
pane.analysisOverlayEl.innerHTML = buildPaneAnalysisMarkup(pane);
|
| 6434 |
+
pane.analysisOverlayEl.classList.toggle('active', Boolean(pane.analysisOpen));
|
| 6435 |
+
}
|
| 6436 |
+
};
|
| 6437 |
+
window.renderPaneAnalysisUI = renderPaneAnalysisUI;
|
| 6438 |
+
window.renderPaneCompactGauges = renderPaneCompactGauges;
|
| 6439 |
+
|
| 6440 |
+
bindPaneAnalysisButton = function bindPaneAnalysisButtonOverride(pane) {
|
| 6441 |
+
if (!pane?.analysisButtonEl) return;
|
| 6442 |
+
pane.analysisButtonEl.onclick = (event) => {
|
| 6443 |
+
event.stopPropagation();
|
| 6444 |
+
if (window.Workspace && typeof Workspace.setActivePane === 'function') {
|
| 6445 |
+
Workspace.setActivePane(pane.paneId);
|
| 6446 |
+
}
|
| 6447 |
+
const willOpen = !pane.analysisOpen;
|
| 6448 |
+
setPaneAnalysisOpen(pane, willOpen);
|
| 6449 |
+
renderPaneAnalysisUI(pane);
|
| 6450 |
+
if (willOpen && (!pane.lastAnalysis || !pane.lastAnalysis.payload) && typeof pane.fetchAI === 'function') {
|
| 6451 |
+
pane.fetchAI({ force: true });
|
| 6452 |
+
}
|
| 6453 |
+
};
|
| 6454 |
+
};
|
| 6455 |
+
|
| 6456 |
+
refreshBtn.onclick = () => {
|
| 6457 |
+
const pane = window.Workspace && typeof Workspace.getActivePane === 'function'
|
| 6458 |
+
? Workspace.getActivePane()
|
| 6459 |
+
: null;
|
| 6460 |
+
if (!pane) return;
|
| 6461 |
+
const willOpen = !pane.analysisOpen;
|
| 6462 |
+
setPaneAnalysisOpen(pane, willOpen);
|
| 6463 |
+
renderPaneAnalysisUI(pane);
|
| 6464 |
+
if (willOpen && typeof pane.fetchAI === 'function') {
|
| 6465 |
+
pane.fetchAI({ force: !pane.lastAnalysis?.payload });
|
| 6466 |
+
}
|
| 6467 |
+
};
|
| 6468 |
+
|
| 6469 |
+
if (window.Workspace && Workspace.panes) {
|
| 6470 |
+
Workspace.panes.forEach((pane) => {
|
| 6471 |
+
bindPaneAnalysisButton(pane);
|
| 6472 |
+
renderPaneAnalysisUI(pane);
|
| 6473 |
+
});
|
| 6474 |
+
}
|
| 6475 |
+
|
| 6476 |
+
function clearFullscreenPaneSelection() {
|
| 6477 |
+
if (!window.Workspace || !Workspace.panes) return;
|
| 6478 |
+
Workspace.panes.forEach((pane) => {
|
| 6479 |
+
pane.analysisOpen = false;
|
| 6480 |
+
if (pane.analysisButtonEl) pane.analysisButtonEl.classList.remove('active');
|
| 6481 |
+
});
|
| 6482 |
+
}
|
| 6483 |
+
|
| 6484 |
+
function openPaneFullscreenAnalysis(pane, options = {}) {
|
| 6485 |
+
if (!pane) return;
|
| 6486 |
+
if (window.Workspace && typeof Workspace.setActivePane === 'function') {
|
| 6487 |
+
Workspace.setActivePane(pane.paneId);
|
| 6488 |
+
}
|
| 6489 |
+
|
| 6490 |
+
const showPayload = (payload) => {
|
| 6491 |
+
if (!payload) return;
|
| 6492 |
+
clearFullscreenPaneSelection();
|
| 6493 |
+
pane.analysisOpen = true;
|
| 6494 |
+
if (pane.analysisButtonEl) pane.analysisButtonEl.classList.add('active');
|
| 6495 |
+
renderAnalysisPanel(pane.symbol, pane.interval, payload);
|
| 6496 |
+
analysisPanel.classList.add('active');
|
| 6497 |
+
setTimeout(updateDashboardScale, 10);
|
| 6498 |
+
};
|
| 6499 |
+
|
| 6500 |
+
if (pane.lastAnalysis?.payload && !options.force) {
|
| 6501 |
+
showPayload(pane.lastAnalysis.payload);
|
| 6502 |
+
return;
|
| 6503 |
+
}
|
| 6504 |
+
|
| 6505 |
+
analysisPanel.classList.add('active');
|
| 6506 |
+
analysisPanel.innerHTML = `
|
| 6507 |
+
<div class="dash-loading">
|
| 6508 |
+
<div class="loader-ring" style="width:48px;height:48px;"></div>
|
| 6509 |
+
<p>AI Δang khα»i tαΊ‘o dα»― liα»u cho ${pane.symbol} ${pane.interval}...</p>
|
| 6510 |
+
</div>
|
| 6511 |
+
`;
|
| 6512 |
+
|
| 6513 |
+
if (typeof pane.fetchAI === 'function') {
|
| 6514 |
+
pane.fetchAI({ force: true }).then((payload) => {
|
| 6515 |
+
if (payload) showPayload(payload);
|
| 6516 |
+
});
|
| 6517 |
+
}
|
| 6518 |
+
}
|
| 6519 |
+
|
| 6520 |
+
bindPaneAnalysisButton = function bindPaneAnalysisButtonFullscreen(pane) {
|
| 6521 |
+
if (!pane?.analysisButtonEl) return;
|
| 6522 |
+
pane.analysisButtonEl.onclick = (event) => {
|
| 6523 |
+
event.stopPropagation();
|
| 6524 |
+
openPaneFullscreenAnalysis(pane);
|
| 6525 |
+
};
|
| 6526 |
+
};
|
| 6527 |
+
|
| 6528 |
+
refreshBtn.onclick = () => {
|
| 6529 |
+
const pane = window.Workspace && typeof Workspace.getActivePane === 'function'
|
| 6530 |
+
? Workspace.getActivePane()
|
| 6531 |
+
: null;
|
| 6532 |
+
if (!pane) return;
|
| 6533 |
+
openPaneFullscreenAnalysis(pane);
|
| 6534 |
+
};
|
| 6535 |
+
|
| 6536 |
+
renderPaneAnalysisUI = function renderPaneAnalysisUIFullscreen(pane) {
|
| 6537 |
+
if (!pane) return;
|
| 6538 |
+
if (pane.analysisButtonEl) {
|
| 6539 |
+
let state = 'idle';
|
| 6540 |
+
if (pane.analysisFetchController) state = 'loading';
|
| 6541 |
+
else if (pane.lastAnalysis && pane.lastAnalysis.payload) state = 'ready';
|
| 6542 |
+
else if (pane.error) state = 'error';
|
| 6543 |
+
pane.analysisButtonEl.dataset.state = state;
|
| 6544 |
+
pane.analysisButtonEl.setAttribute('aria-label', `PhΓ’n tΓch ${pane.symbol} ${pane.interval}`);
|
| 6545 |
+
pane.analysisButtonEl.classList.toggle('active', Boolean(pane.analysisOpen));
|
| 6546 |
+
}
|
| 6547 |
+
renderPaneCompactGauges(pane);
|
| 6548 |
+
};
|
| 6549 |
+
window.renderPaneAnalysisUI = renderPaneAnalysisUI;
|
| 6550 |
+
|
| 6551 |
+
document.addEventListener('click', (event) => {
|
| 6552 |
+
const closeBtn = event.target.closest('#dashCloseBtn');
|
| 6553 |
+
if (!closeBtn) return;
|
| 6554 |
+
clearFullscreenPaneSelection();
|
| 6555 |
+
});
|
| 6556 |
+
|
| 6557 |
+
if (window.Workspace && Workspace.panes) {
|
| 6558 |
+
Workspace.panes.forEach((pane) => {
|
| 6559 |
+
bindPaneAnalysisButton(pane);
|
| 6560 |
+
renderPaneAnalysisUI(pane);
|
| 6561 |
+
if (pane.gaugesEl) {
|
| 6562 |
+
pane.gaugesEl.querySelectorAll('.compact-gauge-card').forEach((card) => {
|
| 6563 |
+
card.onclick = (event) => {
|
| 6564 |
+
event.stopPropagation();
|
| 6565 |
+
openPaneFullscreenAnalysis(pane);
|
| 6566 |
+
};
|
| 6567 |
+
});
|
| 6568 |
+
}
|
| 6569 |
+
});
|
| 6570 |
+
}
|
| 6571 |
</script>
|
| 6572 |
</body>
|
| 6573 |
|
frontend/workspace.css
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
KRONOS MULTI-CHART WORKSPACE β CSS
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
/* ββ Layout Switcher (ultra-compact pill) ββββββββββββ */
|
| 6 |
+
.layout-menu {
|
| 7 |
+
position: relative;
|
| 8 |
+
flex: 0 0 auto;
|
| 9 |
+
min-width: 0;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.layout-menu-button {
|
| 13 |
+
height: var(--ctrl-h);
|
| 14 |
+
min-width: 78px;
|
| 15 |
+
padding: 0 12px;
|
| 16 |
+
display: inline-flex;
|
| 17 |
+
align-items: center;
|
| 18 |
+
justify-content: center;
|
| 19 |
+
gap: 8px;
|
| 20 |
+
border-radius: var(--radius);
|
| 21 |
+
border: 1px solid var(--bdr-base);
|
| 22 |
+
background: var(--bg-control);
|
| 23 |
+
color: var(--txt-secondary);
|
| 24 |
+
font-family: var(--ff-display);
|
| 25 |
+
font-size: 0.78rem;
|
| 26 |
+
font-weight: 700;
|
| 27 |
+
letter-spacing: 0.08em;
|
| 28 |
+
text-transform: uppercase;
|
| 29 |
+
cursor: pointer;
|
| 30 |
+
transition: all 0.2s ease;
|
| 31 |
+
white-space: nowrap;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.layout-menu-button:hover,
|
| 35 |
+
.layout-menu.open .layout-menu-button {
|
| 36 |
+
border-color: rgba(88, 170, 255, 0.52);
|
| 37 |
+
box-shadow: 0 10px 24px rgba(34, 123, 255, 0.16);
|
| 38 |
+
color: var(--txt-primary);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.layout-menu-current {
|
| 42 |
+
display: inline-flex;
|
| 43 |
+
align-items: center;
|
| 44 |
+
justify-content: center;
|
| 45 |
+
min-width: 22px;
|
| 46 |
+
height: 22px;
|
| 47 |
+
padding: 0 6px;
|
| 48 |
+
border-radius: 999px;
|
| 49 |
+
background: rgba(34, 211, 238, 0.14);
|
| 50 |
+
color: var(--accent);
|
| 51 |
+
font-family: var(--ff-mono, 'Space Mono', monospace);
|
| 52 |
+
font-size: 0.68rem;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.layout-menu-popup {
|
| 56 |
+
position: absolute;
|
| 57 |
+
top: calc(100% + 10px);
|
| 58 |
+
right: 0;
|
| 59 |
+
min-width: 190px;
|
| 60 |
+
padding: 10px;
|
| 61 |
+
border-radius: 18px;
|
| 62 |
+
border: 1px solid rgba(255, 255, 255, 0.32);
|
| 63 |
+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(241, 247, 255, 0.9) 100%);
|
| 64 |
+
box-shadow: 0 26px 56px rgba(28, 56, 108, 0.18);
|
| 65 |
+
backdrop-filter: blur(22px) saturate(175%);
|
| 66 |
+
-webkit-backdrop-filter: blur(22px) saturate(175%);
|
| 67 |
+
display: grid;
|
| 68 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 69 |
+
gap: 8px;
|
| 70 |
+
opacity: 0;
|
| 71 |
+
pointer-events: none;
|
| 72 |
+
transform: translateY(-6px) scale(0.98);
|
| 73 |
+
transform-origin: top right;
|
| 74 |
+
transition: opacity 0.18s ease, transform 0.18s ease;
|
| 75 |
+
z-index: 120;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.layout-menu.open .layout-menu-popup {
|
| 79 |
+
opacity: 1;
|
| 80 |
+
pointer-events: auto;
|
| 81 |
+
transform: translateY(0) scale(1);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.layout-menu-option {
|
| 85 |
+
min-height: 58px;
|
| 86 |
+
border: 1px solid rgba(120, 157, 218, 0.18);
|
| 87 |
+
border-radius: 14px;
|
| 88 |
+
background: rgba(255, 255, 255, 0.52);
|
| 89 |
+
color: var(--txt-secondary);
|
| 90 |
+
display: flex;
|
| 91 |
+
flex-direction: column;
|
| 92 |
+
align-items: flex-start;
|
| 93 |
+
justify-content: center;
|
| 94 |
+
gap: 3px;
|
| 95 |
+
padding: 10px 12px;
|
| 96 |
+
cursor: pointer;
|
| 97 |
+
transition: all 0.18s ease;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.layout-menu-option:hover,
|
| 101 |
+
.layout-menu-option.active {
|
| 102 |
+
border-color: rgba(64, 154, 255, 0.46);
|
| 103 |
+
color: var(--txt-primary);
|
| 104 |
+
box-shadow: 0 12px 24px rgba(51, 110, 194, 0.14);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.layout-menu-option strong {
|
| 108 |
+
font-size: 0.88rem;
|
| 109 |
+
line-height: 1;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.layout-menu-option span {
|
| 113 |
+
font-size: 0.64rem;
|
| 114 |
+
opacity: 0.78;
|
| 115 |
+
letter-spacing: 0.04em;
|
| 116 |
+
text-transform: uppercase;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* ββ Workspace Grid ββββββββββββββββββββββββββββββββββ */
|
| 120 |
+
.workspace-grid {
|
| 121 |
+
display: grid;
|
| 122 |
+
width: 100%;
|
| 123 |
+
height: 100%;
|
| 124 |
+
gap: 2px;
|
| 125 |
+
padding: 0;
|
| 126 |
+
position: relative;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.workspace-grid.layout-1 {
|
| 130 |
+
grid-template-columns: 1fr;
|
| 131 |
+
grid-template-rows: 1fr;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.workspace-grid.layout-2 {
|
| 135 |
+
grid-template-columns: 1fr 1fr;
|
| 136 |
+
grid-template-rows: 1fr;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.workspace-grid.layout-4 {
|
| 140 |
+
grid-template-columns: 1fr 1fr;
|
| 141 |
+
grid-template-rows: 1fr 1fr;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.workspace-grid.layout-8 {
|
| 145 |
+
grid-template-columns: repeat(4, 1fr);
|
| 146 |
+
grid-template-rows: 1fr 1fr;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/* ββ Chart Pane ββββββββββββββββββββββββββββββββββββββ */
|
| 150 |
+
.chart-pane {
|
| 151 |
+
position: relative;
|
| 152 |
+
border: 1px solid var(--bdr-muted);
|
| 153 |
+
border-radius: 8px;
|
| 154 |
+
overflow: hidden;
|
| 155 |
+
background: var(--bg-depth);
|
| 156 |
+
transition: border-color 0.25s ease, box-shadow 0.25s ease;
|
| 157 |
+
min-height: 0;
|
| 158 |
+
min-width: 0;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.chart-pane:hover {
|
| 162 |
+
border-color: var(--bdr-subtle, rgba(34, 211, 238, 0.2));
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.chart-pane.active {
|
| 166 |
+
border-color: var(--accent);
|
| 167 |
+
box-shadow:
|
| 168 |
+
0 0 0 1px var(--accent),
|
| 169 |
+
inset 0 0 20px rgba(34, 211, 238, 0.04);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/* ββ Pane Mini Header ββββββββββββββββββββββββββββββββ */
|
| 173 |
+
.pane-header-mini {
|
| 174 |
+
position: absolute;
|
| 175 |
+
top: 3px;
|
| 176 |
+
left: 6px;
|
| 177 |
+
z-index: 10;
|
| 178 |
+
display: flex;
|
| 179 |
+
align-items: center;
|
| 180 |
+
gap: 4px;
|
| 181 |
+
font-family: var(--ff-display);
|
| 182 |
+
font-size: 0.62rem;
|
| 183 |
+
opacity: 0.85;
|
| 184 |
+
pointer-events: none;
|
| 185 |
+
user-select: none;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.layout-1 .pane-header-mini {
|
| 189 |
+
display: none; /* single pane doesn't need mini header */
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.pane-symbol {
|
| 193 |
+
font-weight: 700;
|
| 194 |
+
color: var(--accent);
|
| 195 |
+
letter-spacing: 0.03em;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.pane-sep {
|
| 199 |
+
color: var(--txt-muted);
|
| 200 |
+
opacity: 0.5;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.pane-interval {
|
| 204 |
+
font-weight: 500;
|
| 205 |
+
color: var(--txt-secondary);
|
| 206 |
+
text-transform: uppercase;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.pane-price {
|
| 210 |
+
font-weight: 600;
|
| 211 |
+
color: var(--txt-primary);
|
| 212 |
+
margin-left: 3px;
|
| 213 |
+
font-family: var(--ff-mono, 'Space Mono', monospace);
|
| 214 |
+
font-size: 0.6rem;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/* ββ Pane Chart Container ββββββββββββββββββββββββββββ */
|
| 218 |
+
.pane-chart {
|
| 219 |
+
width: 100%;
|
| 220 |
+
height: 100%;
|
| 221 |
+
position: absolute;
|
| 222 |
+
top: 0;
|
| 223 |
+
left: 0;
|
| 224 |
+
right: 0;
|
| 225 |
+
bottom: 0;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/* ββ Pane Loader βββββββββββββββββββββββββββββββββββββ */
|
| 229 |
+
.pane-loader {
|
| 230 |
+
position: absolute;
|
| 231 |
+
inset: 0;
|
| 232 |
+
display: flex;
|
| 233 |
+
align-items: center;
|
| 234 |
+
justify-content: center;
|
| 235 |
+
background: rgba(4, 13, 30, 0.5);
|
| 236 |
+
backdrop-filter: blur(4px);
|
| 237 |
+
z-index: 15;
|
| 238 |
+
transition: opacity 0.3s ease;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.pane-loader.hidden {
|
| 242 |
+
opacity: 0;
|
| 243 |
+
pointer-events: none;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.pane-loader .loader-ring {
|
| 247 |
+
width: 28px;
|
| 248 |
+
height: 28px;
|
| 249 |
+
border-radius: 50%;
|
| 250 |
+
border: 2px solid rgba(40, 80, 140, 0.15);
|
| 251 |
+
border-top-color: var(--accent);
|
| 252 |
+
animation: spin 0.8s linear infinite;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/* ββ Pane Gauges βββββββββββββββββββββββββββββββββββββ */
|
| 256 |
+
.pane-gauges {
|
| 257 |
+
position: absolute;
|
| 258 |
+
top: 30px;
|
| 259 |
+
left: 6px;
|
| 260 |
+
right: auto;
|
| 261 |
+
z-index: 10;
|
| 262 |
+
display: flex;
|
| 263 |
+
gap: 5px;
|
| 264 |
+
flex-wrap: wrap;
|
| 265 |
+
justify-content: flex-start;
|
| 266 |
+
max-width: min(68%, 288px);
|
| 267 |
+
pointer-events: auto;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.layout-1 .pane-gauges {
|
| 271 |
+
top: 40px;
|
| 272 |
+
max-width: min(72%, 400px);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.pane-gauge-chip {
|
| 276 |
+
min-height: 22px;
|
| 277 |
+
padding: 0 8px;
|
| 278 |
+
border-radius: 999px;
|
| 279 |
+
border: 1px solid rgba(110, 156, 229, 0.24);
|
| 280 |
+
background: rgba(5, 17, 36, 0.78);
|
| 281 |
+
color: rgba(225, 236, 255, 0.92);
|
| 282 |
+
display: inline-flex;
|
| 283 |
+
align-items: center;
|
| 284 |
+
gap: 6px;
|
| 285 |
+
font-family: var(--ff-mono, 'Space Mono', monospace);
|
| 286 |
+
font-size: 0.62rem;
|
| 287 |
+
line-height: 1;
|
| 288 |
+
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
|
| 289 |
+
backdrop-filter: blur(10px);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.pane-gauge-chip b {
|
| 293 |
+
font-size: 0.66rem;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.pane-gauge-chip[data-tone="bull"] {
|
| 297 |
+
border-color: rgba(16, 185, 129, 0.34);
|
| 298 |
+
color: #c9ffe9;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.pane-gauge-chip[data-tone="bear"] {
|
| 302 |
+
border-color: rgba(244, 63, 94, 0.34);
|
| 303 |
+
color: #ffd2dc;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.pane-gauge-chip[data-tone="flat"] {
|
| 307 |
+
border-color: rgba(96, 165, 250, 0.3);
|
| 308 |
+
color: #d7e9ff;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.pane-gauge-verdict {
|
| 312 |
+
min-height: 22px;
|
| 313 |
+
padding: 0 9px;
|
| 314 |
+
border-radius: 999px;
|
| 315 |
+
background: rgba(34, 211, 238, 0.14);
|
| 316 |
+
border: 1px solid rgba(34, 211, 238, 0.28);
|
| 317 |
+
color: var(--accent);
|
| 318 |
+
display: inline-flex;
|
| 319 |
+
align-items: center;
|
| 320 |
+
font-family: var(--ff-display);
|
| 321 |
+
font-size: 0.6rem;
|
| 322 |
+
font-weight: 700;
|
| 323 |
+
letter-spacing: 0.08em;
|
| 324 |
+
text-transform: uppercase;
|
| 325 |
+
box-shadow: 0 10px 24px rgba(18, 87, 136, 0.18);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.pane-analysis-btn {
|
| 329 |
+
position: absolute;
|
| 330 |
+
top: 6px;
|
| 331 |
+
right: 6px;
|
| 332 |
+
z-index: 12;
|
| 333 |
+
min-width: 52px;
|
| 334 |
+
height: 22px;
|
| 335 |
+
padding: 0 7px;
|
| 336 |
+
border-radius: 999px;
|
| 337 |
+
border: 1px solid rgba(110, 156, 229, 0.26);
|
| 338 |
+
background: rgba(7, 18, 38, 0.84);
|
| 339 |
+
color: rgba(229, 239, 255, 0.92);
|
| 340 |
+
display: inline-flex;
|
| 341 |
+
align-items: center;
|
| 342 |
+
justify-content: center;
|
| 343 |
+
gap: 6px;
|
| 344 |
+
font-family: var(--ff-display);
|
| 345 |
+
font-size: 0.54rem;
|
| 346 |
+
font-weight: 700;
|
| 347 |
+
letter-spacing: 0.08em;
|
| 348 |
+
text-transform: uppercase;
|
| 349 |
+
cursor: pointer;
|
| 350 |
+
box-shadow: 0 10px 28px rgba(2, 8, 23, 0.28);
|
| 351 |
+
backdrop-filter: blur(10px);
|
| 352 |
+
transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.pane-analysis-btn:hover {
|
| 356 |
+
transform: translateY(-1px);
|
| 357 |
+
border-color: rgba(34, 211, 238, 0.42);
|
| 358 |
+
box-shadow: 0 12px 28px rgba(8, 47, 73, 0.28);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
.pane-analysis-btn .dot {
|
| 362 |
+
width: 6px;
|
| 363 |
+
height: 6px;
|
| 364 |
+
border-radius: 50%;
|
| 365 |
+
background: rgba(148, 163, 184, 0.9);
|
| 366 |
+
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.14);
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.pane-analysis-btn[data-state="loading"] .dot {
|
| 370 |
+
background: #f59e0b;
|
| 371 |
+
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.16);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.pane-analysis-btn[data-state="ready"] .dot {
|
| 375 |
+
background: #10b981;
|
| 376 |
+
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.16);
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.pane-analysis-btn[data-state="error"] .dot {
|
| 380 |
+
background: #f43f5e;
|
| 381 |
+
box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.16);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
.pane-analysis-btn.active {
|
| 385 |
+
border-color: rgba(34, 211, 238, 0.48);
|
| 386 |
+
color: #effbff;
|
| 387 |
+
box-shadow: 0 12px 28px rgba(8, 47, 73, 0.36);
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.pane-analysis-overlay {
|
| 391 |
+
display: none !important;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.pane-analysis-overlay.active {
|
| 395 |
+
display: none !important;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.pane-analysis-title {
|
| 399 |
+
display: flex;
|
| 400 |
+
align-items: center;
|
| 401 |
+
justify-content: space-between;
|
| 402 |
+
gap: 10px;
|
| 403 |
+
margin-bottom: 10px;
|
| 404 |
+
font-family: var(--ff-display);
|
| 405 |
+
font-size: 0.72rem;
|
| 406 |
+
font-weight: 700;
|
| 407 |
+
letter-spacing: 0.08em;
|
| 408 |
+
text-transform: uppercase;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.pane-analysis-meta {
|
| 412 |
+
display: inline-flex;
|
| 413 |
+
align-items: center;
|
| 414 |
+
gap: 8px;
|
| 415 |
+
flex-wrap: wrap;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.pane-analysis-pill {
|
| 419 |
+
min-height: 21px;
|
| 420 |
+
padding: 0 8px;
|
| 421 |
+
border-radius: 999px;
|
| 422 |
+
border: 1px solid rgba(110, 156, 229, 0.22);
|
| 423 |
+
background: rgba(255, 255, 255, 0.04);
|
| 424 |
+
display: inline-flex;
|
| 425 |
+
align-items: center;
|
| 426 |
+
font-size: 0.58rem;
|
| 427 |
+
letter-spacing: 0.04em;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.pane-analysis-grid {
|
| 431 |
+
display: grid;
|
| 432 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 433 |
+
gap: 8px;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.pane-analysis-card {
|
| 437 |
+
border-radius: 12px;
|
| 438 |
+
border: 1px solid rgba(110, 156, 229, 0.18);
|
| 439 |
+
background: rgba(255, 255, 255, 0.04);
|
| 440 |
+
padding: 9px 10px;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.pane-analysis-card strong {
|
| 444 |
+
display: block;
|
| 445 |
+
margin-bottom: 5px;
|
| 446 |
+
font-size: 0.62rem;
|
| 447 |
+
letter-spacing: 0.06em;
|
| 448 |
+
text-transform: uppercase;
|
| 449 |
+
color: rgba(180, 214, 255, 0.92);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.pane-analysis-body {
|
| 453 |
+
font-size: 0.72rem;
|
| 454 |
+
line-height: 1.45;
|
| 455 |
+
color: rgba(235, 244, 255, 0.92);
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
.pane-analysis-loading,
|
| 459 |
+
.pane-analysis-empty {
|
| 460 |
+
min-height: 92px;
|
| 461 |
+
display: flex;
|
| 462 |
+
align-items: center;
|
| 463 |
+
justify-content: center;
|
| 464 |
+
text-align: center;
|
| 465 |
+
font-size: 0.74rem;
|
| 466 |
+
color: rgba(210, 224, 244, 0.86);
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
/* ββ Multi-pane responsive βββββββββββββββββββββββββββ */
|
| 470 |
+
.pane-gauges .compact-gauge-card {
|
| 471 |
+
min-width: 66px;
|
| 472 |
+
width: 66px;
|
| 473 |
+
min-height: 82px;
|
| 474 |
+
padding: 5px 4px 6px;
|
| 475 |
+
border-radius: 11px;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.pane-gauges .compact-gauge-title {
|
| 479 |
+
font-size: 0.5rem;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.pane-gauges .compact-gauge-signal {
|
| 483 |
+
font-size: 0.46rem;
|
| 484 |
+
line-height: 1.15;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.pane-analysis-sheet .dash-gauges-hero {
|
| 488 |
+
gap: 10px;
|
| 489 |
+
margin-bottom: 10px;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.pane-analysis-sheet .gauge-hero-card {
|
| 493 |
+
min-height: auto;
|
| 494 |
+
padding: 12px 10px 10px;
|
| 495 |
+
border-radius: 14px;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.pane-analysis-sheet .gauge-hero-title {
|
| 499 |
+
font-size: 0.62rem;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.pane-analysis-sheet .gauge-hero-signal {
|
| 503 |
+
font-size: 0.64rem;
|
| 504 |
+
padding: 6px 10px;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.pane-analysis-sheet .gauge-hero-counts {
|
| 508 |
+
gap: 8px;
|
| 509 |
+
font-size: 0.58rem;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.pane-analysis-sheet .gh-ai-row {
|
| 513 |
+
font-size: 0.6rem;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
.pane-analysis-sheet .dash-tables-row {
|
| 517 |
+
gap: 10px;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.pane-analysis-sheet .dash-col {
|
| 521 |
+
min-height: 0;
|
| 522 |
+
max-height: 220px;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.pane-analysis-sheet .dash-table-wrap {
|
| 526 |
+
max-height: 170px;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
.pane-analysis-sheet .dt,
|
| 530 |
+
.pane-analysis-sheet .pivot-table {
|
| 531 |
+
font-size: 0.6rem;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
.pane-analysis-sheet .dc-header {
|
| 535 |
+
font-size: 0.68rem;
|
| 536 |
+
margin-bottom: 8px;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.pane-analysis-sheet .summary-disclaimer {
|
| 540 |
+
margin-top: 10px;
|
| 541 |
+
padding: 10px 12px;
|
| 542 |
+
font-size: 0.66rem;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
@media (max-width: 900px) {
|
| 546 |
+
.layout-menu-popup {
|
| 547 |
+
right: auto;
|
| 548 |
+
left: 0;
|
| 549 |
+
transform-origin: top left;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
.workspace-grid.layout-8 {
|
| 553 |
+
grid-template-columns: repeat(2, 1fr);
|
| 554 |
+
grid-template-rows: repeat(4, 1fr);
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
@media (max-width: 600px) {
|
| 559 |
+
.layout-menu-button {
|
| 560 |
+
min-width: 62px;
|
| 561 |
+
padding: 0 10px;
|
| 562 |
+
gap: 6px;
|
| 563 |
+
font-size: 0.72rem;
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.layout-menu-popup {
|
| 567 |
+
min-width: 168px;
|
| 568 |
+
grid-template-columns: 1fr;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.workspace-grid.layout-4 {
|
| 572 |
+
grid-template-columns: 1fr;
|
| 573 |
+
grid-template-rows: repeat(4, 1fr);
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
.workspace-grid.layout-2 {
|
| 577 |
+
grid-template-columns: 1fr;
|
| 578 |
+
grid-template-rows: 1fr 1fr;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.workspace-grid.layout-8 {
|
| 582 |
+
grid-template-columns: 1fr;
|
| 583 |
+
grid-template-rows: repeat(8, 1fr);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.pane-gauges,
|
| 587 |
+
.layout-1 .pane-gauges {
|
| 588 |
+
max-width: calc(100% - 16px);
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
.pane-analysis-grid {
|
| 592 |
+
grid-template-columns: 1fr;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.pane-analysis-overlay {
|
| 596 |
+
max-height: min(56%, 320px);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.pane-analysis-sheet .dash-gauges-hero,
|
| 600 |
+
.pane-analysis-sheet .dash-tables-row {
|
| 601 |
+
grid-template-columns: 1fr;
|
| 602 |
+
}
|
| 603 |
+
}
|
frontend/workspace.js
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
* KRONOS MULTI-CHART WORKSPACE ENGINE (V1)
|
| 4 |
+
* Provides: PaneState, ChartPaneController, StreamManager,
|
| 5 |
+
* DataCoordinator, WorkspaceController
|
| 6 |
+
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
/* ββ Constants ββββββββββββββββββββββββββββββββββββββ */
|
| 10 |
+
const WORKSPACE_STORAGE_KEY = 'kronos_workspace';
|
| 11 |
+
const CHART_HISTORY_LIMIT_WS = 500;
|
| 12 |
+
const MAX_CONCURRENT_FETCHES = 4;
|
| 13 |
+
const WS_RECONNECT_DELAY = 5000;
|
| 14 |
+
const LAYOUT_PRESETS = [1, 2, 4, 8];
|
| 15 |
+
const LAYOUT_GRID_MAP = {
|
| 16 |
+
1: { cols: 1, rows: 1 },
|
| 17 |
+
2: { cols: 2, rows: 1 },
|
| 18 |
+
4: { cols: 2, rows: 2 },
|
| 19 |
+
8: { cols: 4, rows: 2 },
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
PaneState β Per-pane data container
|
| 24 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 25 |
+
class PaneState {
|
| 26 |
+
constructor(id, symbol = 'XAUUSD', interval = '1d') {
|
| 27 |
+
this.paneId = id;
|
| 28 |
+
this.symbol = symbol;
|
| 29 |
+
this.interval = interval;
|
| 30 |
+
this.indicatorMode = 'none';
|
| 31 |
+
this.horizon = 10;
|
| 32 |
+
|
| 33 |
+
// Chart instances (set by ChartPaneController)
|
| 34 |
+
this.chartInstance = null;
|
| 35 |
+
this.candleSeries = null;
|
| 36 |
+
this.forecastSeries = { candles: null, p50: null, p10: null, p90: null, segments: [] };
|
| 37 |
+
this.indicatorSeries = { bbUpper: null, bbMid: null, bbLower: null, rsi: null };
|
| 38 |
+
|
| 39 |
+
// Network
|
| 40 |
+
this.fetchController = null;
|
| 41 |
+
this.analysisFetchController = null;
|
| 42 |
+
this.analysisRequestPromise = null;
|
| 43 |
+
this.analysisRequestKey = null;
|
| 44 |
+
this.analysisRetryTimer = null;
|
| 45 |
+
|
| 46 |
+
// Data
|
| 47 |
+
this.lastCandleData = null;
|
| 48 |
+
this.lastAnalysis = { payload: null, symbol: null, interval: null };
|
| 49 |
+
this.chartContext = { symbol: null, interval: null };
|
| 50 |
+
this.forecastContext = { symbol: null, interval: null, ready: false };
|
| 51 |
+
this.priceFormat = { precision: 2, minMove: 0.01 };
|
| 52 |
+
|
| 53 |
+
// UI
|
| 54 |
+
this.loading = false;
|
| 55 |
+
this.error = null;
|
| 56 |
+
|
| 57 |
+
// DOM refs (set during mount)
|
| 58 |
+
this.containerEl = null;
|
| 59 |
+
this.chartEl = null;
|
| 60 |
+
this.loaderEl = null;
|
| 61 |
+
this.gaugesEl = null;
|
| 62 |
+
this.paneHeaderEl = null;
|
| 63 |
+
this.priceEl = null;
|
| 64 |
+
this.analysisButtonEl = null;
|
| 65 |
+
this.analysisOverlayEl = null;
|
| 66 |
+
this.analysisOpen = false;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
hasMatchingAnalysis() {
|
| 70 |
+
return Boolean(
|
| 71 |
+
this.lastAnalysis &&
|
| 72 |
+
this.lastAnalysis.payload &&
|
| 73 |
+
this.lastAnalysis.symbol === this.symbol &&
|
| 74 |
+
this.lastAnalysis.interval === this.interval
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
hasMatchingForecast() {
|
| 79 |
+
return Boolean(
|
| 80 |
+
this.forecastContext &&
|
| 81 |
+
this.forecastContext.ready &&
|
| 82 |
+
this.forecastContext.symbol === this.symbol &&
|
| 83 |
+
this.forecastContext.interval === this.interval
|
| 84 |
+
);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async fetchAI(options = {}) {
|
| 89 |
+
const horizon = this.horizon || 24;
|
| 90 |
+
const requestSymbol = this.symbol;
|
| 91 |
+
const requestInterval = this.interval;
|
| 92 |
+
const requestHorizon = horizon;
|
| 93 |
+
const requestKey = `${requestSymbol}|${requestInterval}|${requestHorizon}`;
|
| 94 |
+
|
| 95 |
+
if (!options.force && this.analysisRequestPromise && this.analysisRequestKey === requestKey) {
|
| 96 |
+
return this.analysisRequestPromise;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
if (this.analysisRetryTimer) {
|
| 100 |
+
clearTimeout(this.analysisRetryTimer);
|
| 101 |
+
this.analysisRetryTimer = null;
|
| 102 |
+
}
|
| 103 |
+
if (this.analysisFetchController) {
|
| 104 |
+
this.analysisFetchController.abort();
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
if (typeof window.clearPaneForecastCandlesOnly === 'function') {
|
| 108 |
+
window.clearPaneForecastCandlesOnly(this);
|
| 109 |
+
} else if (this.forecastSeries?.candles?.setData) {
|
| 110 |
+
this.forecastSeries.candles.setData([]);
|
| 111 |
+
if (this.forecastSeries.candles.applyOptions) {
|
| 112 |
+
this.forecastSeries.candles.applyOptions({ visible: false });
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const controller = new AbortController();
|
| 117 |
+
this.analysisFetchController = controller;
|
| 118 |
+
this.analysisRequestKey = requestKey;
|
| 119 |
+
|
| 120 |
+
// Show loading in mini gauges
|
| 121 |
+
const shouldRenderPaneGauges = !(window.Workspace?.layoutPreset === 1 && this.paneId === 'pane-0');
|
| 122 |
+
if (this.gaugesEl && shouldRenderPaneGauges && !this.hasMatchingAnalysis()) {
|
| 123 |
+
this.gaugesEl.innerHTML = '<div class="loader-ring" style="width:16px;height:16px;border:2px solid rgba(40,80,140,0.15);border-top-color:var(--accent);animation:spin 0.8s linear infinite;border-radius:50%;"></div>';
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
const requestPromise = (async () => {
|
| 127 |
+
try {
|
| 128 |
+
const fData = await DataCoordinator.fetchForecast(requestSymbol, requestInterval, requestHorizon, controller.signal);
|
| 129 |
+
if (controller.signal.aborted) return null;
|
| 130 |
+
if (
|
| 131 |
+
this.symbol !== requestSymbol
|
| 132 |
+
|| this.interval !== requestInterval
|
| 133 |
+
|| (this.horizon || 24) !== requestHorizon
|
| 134 |
+
) {
|
| 135 |
+
return null;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const hasAnalysis = Boolean(fData?.analysis);
|
| 139 |
+
if (hasAnalysis) {
|
| 140 |
+
this.lastAnalysis = { payload: fData, symbol: requestSymbol, interval: requestInterval };
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const forecastPoints = Array.isArray(fData.forecast) ? fData.forecast : [];
|
| 144 |
+
if (this.lastCandleData && this.forecastSeries?.p50) {
|
| 145 |
+
const fallbackActualPoint = {
|
| 146 |
+
time: this.lastCandleData.time,
|
| 147 |
+
value: (
|
| 148 |
+
Number(this.lastCandleData.open ?? 0)
|
| 149 |
+
+ Number(this.lastCandleData.high ?? 0)
|
| 150 |
+
+ Number(this.lastCandleData.low ?? 0)
|
| 151 |
+
+ Number(this.lastCandleData.close ?? 0)
|
| 152 |
+
) / 4,
|
| 153 |
+
};
|
| 154 |
+
const forecastLine = typeof window.buildForecastLineFromRows === 'function'
|
| 155 |
+
? window.buildForecastLineFromRows(forecastPoints, fallbackActualPoint)
|
| 156 |
+
: [fallbackActualPoint, ...forecastPoints
|
| 157 |
+
.filter(d => d && d.time !== undefined && d.p50 !== undefined && d.time !== this.lastCandleData.time)
|
| 158 |
+
.map(d => ({ time: d.time, value: d.p50 }))];
|
| 159 |
+
const hasForecast = forecastLine.length > 1;
|
| 160 |
+
|
| 161 |
+
if (hasForecast) {
|
| 162 |
+
if (typeof window.renderPaneForecastVisuals === 'function') {
|
| 163 |
+
window.renderPaneForecastVisuals(this, forecastLine);
|
| 164 |
+
} else {
|
| 165 |
+
if (this.forecastSeries.candles?.setData) {
|
| 166 |
+
this.forecastSeries.candles.setData([]);
|
| 167 |
+
}
|
| 168 |
+
if (this.forecastSeries.p10?.setData) {
|
| 169 |
+
this.forecastSeries.p10.setData([]);
|
| 170 |
+
}
|
| 171 |
+
if (this.forecastSeries.p90?.setData) {
|
| 172 |
+
this.forecastSeries.p90.setData([]);
|
| 173 |
+
}
|
| 174 |
+
this.forecastSeries.p50.setData(forecastLine);
|
| 175 |
+
}
|
| 176 |
+
this.forecastContext = { symbol: requestSymbol, interval: requestInterval, ready: true };
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
if (typeof window.renderPaneAnalysisUI === 'function') {
|
| 181 |
+
window.renderPaneAnalysisUI(this);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
if (
|
| 185 |
+
hasAnalysis &&
|
| 186 |
+
typeof window.Workspace !== 'undefined' &&
|
| 187 |
+
window.Workspace?.activePaneId === this.paneId &&
|
| 188 |
+
typeof window.renderCompactGauges === 'function'
|
| 189 |
+
) {
|
| 190 |
+
window.renderCompactGauges(this.symbol, this.interval, fData);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
if (
|
| 194 |
+
hasAnalysis &&
|
| 195 |
+
this.analysisOpen &&
|
| 196 |
+
typeof window.renderAnalysisPanel === 'function' &&
|
| 197 |
+
typeof document !== 'undefined'
|
| 198 |
+
) {
|
| 199 |
+
const analysisPanel = document.getElementById('analysisPanel');
|
| 200 |
+
if (analysisPanel?.classList.contains('active')) {
|
| 201 |
+
window.renderAnalysisPanel(this.symbol, this.interval, fData);
|
| 202 |
+
if (typeof window.updateDashboardScale === 'function') {
|
| 203 |
+
setTimeout(window.updateDashboardScale, 10);
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
return fData;
|
| 209 |
+
} catch (e) {
|
| 210 |
+
if (e.name === 'AbortError') return null;
|
| 211 |
+
console.error(`[Pane ${this.paneId}] AI fetch error:`, e);
|
| 212 |
+
if (typeof window.renderPaneAnalysisUI === 'function') {
|
| 213 |
+
window.renderPaneAnalysisUI(this);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// Retry
|
| 217 |
+
this.analysisRetryTimer = setTimeout(() => {
|
| 218 |
+
this.analysisRetryTimer = null;
|
| 219 |
+
this.fetchAI({ force: true });
|
| 220 |
+
}, 15000);
|
| 221 |
+
|
| 222 |
+
return null;
|
| 223 |
+
} finally {
|
| 224 |
+
if (this.analysisFetchController === controller) {
|
| 225 |
+
this.analysisFetchController = null;
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
})();
|
| 229 |
+
|
| 230 |
+
this.analysisRequestPromise = requestPromise;
|
| 231 |
+
try {
|
| 232 |
+
return await requestPromise;
|
| 233 |
+
} finally {
|
| 234 |
+
if (this.analysisRequestPromise === requestPromise) {
|
| 235 |
+
this.analysisRequestPromise = null;
|
| 236 |
+
this.analysisRequestKey = null;
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
renderGauges() {
|
| 242 |
+
if (window.Workspace?.layoutPreset === 1 && this.paneId === 'pane-0') {
|
| 243 |
+
if (this.gaugesEl) {
|
| 244 |
+
this.gaugesEl.innerHTML = '';
|
| 245 |
+
this.gaugesEl.style.display = 'none';
|
| 246 |
+
}
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
if (!this.gaugesEl || !this.lastAnalysis || !this.lastAnalysis.payload || !this.lastAnalysis.payload.analysis) {
|
| 250 |
+
if (this.gaugesEl) this.gaugesEl.innerHTML = '';
|
| 251 |
+
return;
|
| 252 |
+
}
|
| 253 |
+
this.gaugesEl.style.display = '';
|
| 254 |
+
const analysis = this.lastAnalysis.payload.analysis;
|
| 255 |
+
const technical = analysis.dashboard?.technical || analysis.technicals || {};
|
| 256 |
+
const ai = analysis.dashboard?.ai || analysis.ai_gauge || {};
|
| 257 |
+
const summary = analysis.dashboard?.summary || analysis.summary || {};
|
| 258 |
+
const verdict = this.lastAnalysis.payload.verdict || summary.signal || '--';
|
| 259 |
+
const tone = (score) => score > 60 ? 'bull' : score < 40 ? 'bear' : 'flat';
|
| 260 |
+
|
| 261 |
+
this.gaugesEl.innerHTML = `
|
| 262 |
+
<div style="width:18px;height:18px;border-radius:50%;border:2px solid ${cT};display:flex;align-items:center;justify-content:center;background:var(--bg-depth); title="Trend">
|
| 263 |
+
<span style="font-size:9px;font-weight:bold;color:${cT}">${tScore > 50 ? 'β' : 'β'}</span>
|
| 264 |
+
</div>
|
| 265 |
+
<div style="width:18px;height:18px;border-radius:50%;border:2px solid ${cS};display:flex;align-items:center;justify-content:center;background:var(--bg-depth); title="Strength">
|
| 266 |
+
<span style="font-size:9px;font-weight:bold;color:${cS}">S</span>
|
| 267 |
+
</div>
|
| 268 |
+
`;
|
| 269 |
+
}
|
| 270 |
+
toJSON() {
|
| 271 |
+
return {
|
| 272 |
+
id: this.paneId,
|
| 273 |
+
symbol: this.symbol,
|
| 274 |
+
interval: this.interval,
|
| 275 |
+
indicator: this.indicatorMode,
|
| 276 |
+
horizon: this.horizon,
|
| 277 |
+
};
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 282 |
+
StreamManager β WebSocket lifecycle & reuse
|
| 283 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 284 |
+
const StreamManager = {
|
| 285 |
+
_streams: new Map(), // key β { ws, callbacks: Map<paneId, fn>, symbol, interval }
|
| 286 |
+
_paneKeys: new Map(), // paneId β key
|
| 287 |
+
|
| 288 |
+
_makeKey(symbol, interval) { return `${symbol}|${interval}`; },
|
| 289 |
+
|
| 290 |
+
subscribe(paneId, symbol, interval, onMessage) {
|
| 291 |
+
const key = this._makeKey(symbol, interval);
|
| 292 |
+
const currentKey = this._paneKeys.get(paneId);
|
| 293 |
+
|
| 294 |
+
if (currentKey === key && this._streams.has(key)) {
|
| 295 |
+
const existingEntry = this._streams.get(key);
|
| 296 |
+
existingEntry.callbacks.set(paneId, onMessage);
|
| 297 |
+
return existingEntry.ws;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
this.unsubscribe(paneId);
|
| 301 |
+
this._paneKeys.set(paneId, key);
|
| 302 |
+
|
| 303 |
+
if (this._streams.has(key)) {
|
| 304 |
+
const entry = this._streams.get(key);
|
| 305 |
+
entry.callbacks.set(paneId, onMessage);
|
| 306 |
+
console.log(`[StreamManager] Reusing WS for ${key}, pane ${paneId} (total: ${entry.callbacks.size})`);
|
| 307 |
+
return entry.ws;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
const apiBase = window.__KRONOS_API_BASE || '';
|
| 311 |
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 312 |
+
const wsUrl = `${apiBase.replace(/^https?:\/\//, wsProtocol)}/ws/price/${symbol}?interval=${encodeURIComponent(interval)}`;
|
| 313 |
+
console.log(`[StreamManager] New WS: ${wsUrl} (pane ${paneId})`);
|
| 314 |
+
|
| 315 |
+
const ws = new WebSocket(wsUrl);
|
| 316 |
+
const entry = {
|
| 317 |
+
ws,
|
| 318 |
+
callbacks: new Map([[paneId, onMessage]]),
|
| 319 |
+
symbol,
|
| 320 |
+
interval,
|
| 321 |
+
};
|
| 322 |
+
this._streams.set(key, entry);
|
| 323 |
+
|
| 324 |
+
ws.onmessage = (event) => {
|
| 325 |
+
try {
|
| 326 |
+
const data = JSON.parse(event.data);
|
| 327 |
+
if (data.error) return;
|
| 328 |
+
if (data.type === 'ping') {
|
| 329 |
+
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
|
| 330 |
+
return;
|
| 331 |
+
}
|
| 332 |
+
for (const [, cb] of entry.callbacks) {
|
| 333 |
+
try { cb(data); } catch (e) { console.warn('[StreamManager] cb error', e); }
|
| 334 |
+
}
|
| 335 |
+
} catch (e) {
|
| 336 |
+
console.warn('[StreamManager] parse error', e);
|
| 337 |
+
}
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
+
ws.onclose = () => {
|
| 341 |
+
console.log(`[StreamManager] WS closed: ${key}`);
|
| 342 |
+
if (this._streams.get(key)?.ws === ws) {
|
| 343 |
+
this._streams.delete(key);
|
| 344 |
+
// Reconnect for remaining subscribers after delay
|
| 345 |
+
const remainingCallbacks = new Map(entry.callbacks);
|
| 346 |
+
if (remainingCallbacks.size > 0) {
|
| 347 |
+
setTimeout(() => {
|
| 348 |
+
for (const [pid, cb] of remainingCallbacks) {
|
| 349 |
+
const pane = Workspace.getPane(pid);
|
| 350 |
+
if (pane && pane.symbol === symbol && pane.interval === interval) {
|
| 351 |
+
this.subscribe(pid, symbol, interval, cb);
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
}, WS_RECONNECT_DELAY);
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
};
|
| 358 |
+
|
| 359 |
+
ws.onerror = (e) => {
|
| 360 |
+
console.warn(`[StreamManager] WS error: ${key}`, e);
|
| 361 |
+
};
|
| 362 |
+
|
| 363 |
+
return ws;
|
| 364 |
+
},
|
| 365 |
+
|
| 366 |
+
unsubscribe(paneId) {
|
| 367 |
+
const key = this._paneKeys.get(paneId);
|
| 368 |
+
if (!key) return;
|
| 369 |
+
this._paneKeys.delete(paneId);
|
| 370 |
+
const entry = this._streams.get(key);
|
| 371 |
+
if (!entry) return;
|
| 372 |
+
entry.callbacks.delete(paneId);
|
| 373 |
+
if (entry.callbacks.size === 0) {
|
| 374 |
+
try { entry.ws.close(); } catch (_) {}
|
| 375 |
+
this._streams.delete(key);
|
| 376 |
+
console.log(`[StreamManager] Closed WS ${key} (no subscribers)`);
|
| 377 |
+
}
|
| 378 |
+
},
|
| 379 |
+
|
| 380 |
+
unsubscribeAll() {
|
| 381 |
+
for (const [key, entry] of this._streams) {
|
| 382 |
+
try { entry.ws.close(); } catch (_) {}
|
| 383 |
+
}
|
| 384 |
+
this._streams.clear();
|
| 385 |
+
this._paneKeys.clear();
|
| 386 |
+
},
|
| 387 |
+
|
| 388 |
+
getActiveCount() {
|
| 389 |
+
return this._streams.size;
|
| 390 |
+
}
|
| 391 |
+
};
|
| 392 |
+
|
| 393 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 394 |
+
DataCoordinator β Request dedup & concurrency
|
| 395 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 396 |
+
const DataCoordinator = {
|
| 397 |
+
_inflightCache: new Map(),
|
| 398 |
+
_concurrency: 0,
|
| 399 |
+
_queue: [],
|
| 400 |
+
|
| 401 |
+
async _throttled(fn) {
|
| 402 |
+
if (this._concurrency >= MAX_CONCURRENT_FETCHES) {
|
| 403 |
+
await new Promise(resolve => this._queue.push(resolve));
|
| 404 |
+
}
|
| 405 |
+
this._concurrency++;
|
| 406 |
+
try {
|
| 407 |
+
return await fn();
|
| 408 |
+
} finally {
|
| 409 |
+
this._concurrency--;
|
| 410 |
+
if (this._queue.length > 0) this._queue.shift()();
|
| 411 |
+
}
|
| 412 |
+
},
|
| 413 |
+
|
| 414 |
+
async fetch(path, signal) {
|
| 415 |
+
const cacheKey = path.split('&_t=')[0]; // strip cache buster for dedup
|
| 416 |
+
if (this._inflightCache.has(cacheKey)) {
|
| 417 |
+
return this._inflightCache.get(cacheKey);
|
| 418 |
+
}
|
| 419 |
+
const promise = this._throttled(() => {
|
| 420 |
+
if (typeof apiRequest === 'function') {
|
| 421 |
+
return apiRequest(path, { signal });
|
| 422 |
+
}
|
| 423 |
+
// Fallback if apiRequest not yet defined
|
| 424 |
+
const apiBase = window.__KRONOS_API_BASE || '';
|
| 425 |
+
const sep = path.includes('?') ? '&' : '?';
|
| 426 |
+
const url = `${apiBase}${path}${sep}_t=${Date.now()}`;
|
| 427 |
+
return fetch(url, { signal }).then(r => {
|
| 428 |
+
if (!r.ok) throw new Error(r.statusText);
|
| 429 |
+
return r.json();
|
| 430 |
+
});
|
| 431 |
+
});
|
| 432 |
+
this._inflightCache.set(cacheKey, promise);
|
| 433 |
+
try {
|
| 434 |
+
return await promise;
|
| 435 |
+
} finally {
|
| 436 |
+
this._inflightCache.delete(cacheKey);
|
| 437 |
+
}
|
| 438 |
+
},
|
| 439 |
+
|
| 440 |
+
async fetchHistorical(symbol, interval, limit, signal) {
|
| 441 |
+
return this.fetch(
|
| 442 |
+
`/api/historical/${encodeURIComponent(symbol)}?interval=${interval}&limit=${limit}`,
|
| 443 |
+
signal
|
| 444 |
+
);
|
| 445 |
+
},
|
| 446 |
+
|
| 447 |
+
async fetchIndicators(symbol, interval, limit, signal) {
|
| 448 |
+
return this.fetch(
|
| 449 |
+
`/api/indicators/${encodeURIComponent(symbol)}?interval=${interval}&limit=${limit}`,
|
| 450 |
+
signal
|
| 451 |
+
);
|
| 452 |
+
},
|
| 453 |
+
|
| 454 |
+
async fetchForecast(symbol, interval, horizon, signal) {
|
| 455 |
+
return this.fetch(
|
| 456 |
+
`/api/forecast/${encodeURIComponent(symbol)}?interval=${interval}&horizon=${horizon}`,
|
| 457 |
+
signal
|
| 458 |
+
);
|
| 459 |
+
}
|
| 460 |
+
};
|
| 461 |
+
|
| 462 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 463 |
+
WorkspaceController β Layout & pane orchestration
|
| 464 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 465 |
+
const Workspace = {
|
| 466 |
+
layoutPreset: 1,
|
| 467 |
+
activePaneId: 'pane-0',
|
| 468 |
+
panes: new Map(),
|
| 469 |
+
_gridEl: null,
|
| 470 |
+
_onActivePaneChange: null, // callback(paneId)
|
| 471 |
+
_onPaneSymbolChange: null, // callback(paneId, symbol, interval)
|
| 472 |
+
|
| 473 |
+
/* ββ Init βββββββββββββββββββββββββββββββββββ */
|
| 474 |
+
init(gridEl) {
|
| 475 |
+
this._gridEl = gridEl || document.getElementById('workspaceGrid');
|
| 476 |
+
},
|
| 477 |
+
|
| 478 |
+
/* ββ Pane CRUD βββββββββββββββββββββββββββββββ */
|
| 479 |
+
createPane(id, symbol, interval) {
|
| 480 |
+
const pane = new PaneState(id, symbol, interval);
|
| 481 |
+
this.panes.set(id, pane);
|
| 482 |
+
return pane;
|
| 483 |
+
},
|
| 484 |
+
|
| 485 |
+
getPane(id) {
|
| 486 |
+
return this.panes.get(id) || null;
|
| 487 |
+
},
|
| 488 |
+
|
| 489 |
+
getActivePane() {
|
| 490 |
+
return this.panes.get(this.activePaneId) || null;
|
| 491 |
+
},
|
| 492 |
+
|
| 493 |
+
destroyPane(id) {
|
| 494 |
+
const pane = this.panes.get(id);
|
| 495 |
+
if (!pane) return;
|
| 496 |
+
|
| 497 |
+
// Cleanup network
|
| 498 |
+
StreamManager.unsubscribe(id);
|
| 499 |
+
if (pane.fetchController) {
|
| 500 |
+
pane.fetchController.abort();
|
| 501 |
+
pane.fetchController = null;
|
| 502 |
+
}
|
| 503 |
+
if (pane.analysisFetchController) {
|
| 504 |
+
pane.analysisFetchController.abort();
|
| 505 |
+
pane.analysisFetchController = null;
|
| 506 |
+
}
|
| 507 |
+
if (pane.analysisRetryTimer) {
|
| 508 |
+
clearTimeout(pane.analysisRetryTimer);
|
| 509 |
+
pane.analysisRetryTimer = null;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
// Cleanup chart
|
| 513 |
+
if (pane.chartInstance) {
|
| 514 |
+
try { pane.chartInstance.remove(); } catch (_) {}
|
| 515 |
+
pane.chartInstance = null;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
// Cleanup DOM
|
| 519 |
+
if (pane.containerEl && pane.containerEl.parentNode) {
|
| 520 |
+
pane.containerEl.parentNode.removeChild(pane.containerEl);
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
this.panes.delete(id);
|
| 524 |
+
},
|
| 525 |
+
|
| 526 |
+
/* ββ Active pane βββββββββββββββββββββββββββββ */
|
| 527 |
+
setActivePane(id) {
|
| 528 |
+
if (!this.panes.has(id)) return;
|
| 529 |
+
const prevId = this.activePaneId;
|
| 530 |
+
this.activePaneId = id;
|
| 531 |
+
|
| 532 |
+
// Update highlight
|
| 533 |
+
if (this._gridEl) {
|
| 534 |
+
this._gridEl.querySelectorAll('.chart-pane').forEach(el => {
|
| 535 |
+
el.classList.toggle('active', el.dataset.paneId === id);
|
| 536 |
+
});
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
if (prevId !== id && this._onActivePaneChange) {
|
| 541 |
+
this._onActivePaneChange(id);
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
// Sync AI UI
|
| 545 |
+
if (window.renderAnalysisPanel && window.renderCompactGauges) {
|
| 546 |
+
const pane = this.panes.get(id);
|
| 547 |
+
if (pane && pane.lastAnalysis && pane.lastAnalysis.payload) {
|
| 548 |
+
window.renderAnalysisPanel(pane.symbol, pane.interval, pane.lastAnalysis.payload);
|
| 549 |
+
window.renderCompactGauges(pane.symbol, pane.interval, pane.lastAnalysis.payload);
|
| 550 |
+
if (window.updateDashboardScale) window.updateDashboardScale();
|
| 551 |
+
} else {
|
| 552 |
+
const panel = document.getElementById('analysisPanel');
|
| 553 |
+
if (panel) panel.innerHTML = '';
|
| 554 |
+
const gContainer = document.getElementById('chartGauges');
|
| 555 |
+
if (gContainer) { gContainer.innerHTML = ''; gContainer.classList.remove('combo-active'); }
|
| 556 |
+
}
|
| 557 |
+
// re-render mini gauges for all
|
| 558 |
+
for (const [pId, p] of this.panes) {
|
| 559 |
+
if (p.renderGauges) p.renderGauges();
|
| 560 |
+
}
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
},
|
| 564 |
+
|
| 565 |
+
/* ββ Layout ββββββββββββββββββββββββββββββββββ */
|
| 566 |
+
setLayout(preset) {
|
| 567 |
+
if (!LAYOUT_PRESETS.includes(preset)) return;
|
| 568 |
+
const prevPreset = this.layoutPreset;
|
| 569 |
+
this.layoutPreset = preset;
|
| 570 |
+
|
| 571 |
+
// Determine which panes to keep, create, or destroy
|
| 572 |
+
const targetCount = preset;
|
| 573 |
+
const currentIds = Array.from(this.panes.keys());
|
| 574 |
+
|
| 575 |
+
// Create new panes if needed
|
| 576 |
+
for (let i = currentIds.length; i < targetCount; i++) {
|
| 577 |
+
const id = `pane-${i}`;
|
| 578 |
+
this.createPane(id, 'XAUUSD', '1d');
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
// Destroy excess panes
|
| 582 |
+
for (let i = targetCount; i < currentIds.length; i++) {
|
| 583 |
+
this.destroyPane(currentIds[i]);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
// Ensure active pane is valid
|
| 587 |
+
if (!this.panes.has(this.activePaneId)) {
|
| 588 |
+
this.activePaneId = `pane-0`;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
// Update grid CSS
|
| 592 |
+
if (this._gridEl) {
|
| 593 |
+
LAYOUT_PRESETS.forEach(lp => this._gridEl.classList.remove(`layout-${lp}`));
|
| 594 |
+
this._gridEl.classList.add(`layout-${preset}`);
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
this.save();
|
| 598 |
+
return { created: targetCount - currentIds.length, destroyed: Math.max(0, currentIds.length - targetCount) };
|
| 599 |
+
},
|
| 600 |
+
|
| 601 |
+
/* ββ Persistence βββββββββββββββββββββββββββββ */
|
| 602 |
+
save() {
|
| 603 |
+
const data = {
|
| 604 |
+
version: 1,
|
| 605 |
+
layoutPreset: this.layoutPreset,
|
| 606 |
+
activePaneId: this.activePaneId,
|
| 607 |
+
panes: Array.from(this.panes.values()).map(p => p.toJSON()),
|
| 608 |
+
};
|
| 609 |
+
try {
|
| 610 |
+
localStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify(data));
|
| 611 |
+
} catch (_) {}
|
| 612 |
+
},
|
| 613 |
+
|
| 614 |
+
restore() {
|
| 615 |
+
try {
|
| 616 |
+
const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY);
|
| 617 |
+
if (!raw) return null;
|
| 618 |
+
const data = JSON.parse(raw);
|
| 619 |
+
if (!data || data.version !== 1) return null;
|
| 620 |
+
return data;
|
| 621 |
+
} catch (_) {
|
| 622 |
+
return null;
|
| 623 |
+
}
|
| 624 |
+
},
|
| 625 |
+
|
| 626 |
+
/* ββ Pane DOM builder ββββββββββββββββββββββββ */
|
| 627 |
+
buildPaneDOM(pane) {
|
| 628 |
+
const container = document.createElement('div');
|
| 629 |
+
container.className = 'chart-pane';
|
| 630 |
+
container.dataset.paneId = pane.paneId;
|
| 631 |
+
if (pane.paneId === this.activePaneId) container.classList.add('active');
|
| 632 |
+
|
| 633 |
+
container.innerHTML = `
|
| 634 |
+
<div class="pane-header-mini">
|
| 635 |
+
<span class="pane-symbol">${pane.symbol}</span>
|
| 636 |
+
<span class="pane-sep">Β·</span>
|
| 637 |
+
<span class="pane-interval">${pane.interval}</span>
|
| 638 |
+
<span class="pane-price">--</span>
|
| 639 |
+
</div>
|
| 640 |
+
<div class="pane-chart" id="pane-chart-${pane.paneId}"></div>
|
| 641 |
+
<div class="pane-loader hidden">
|
| 642 |
+
<div class="loader-ring"></div>
|
| 643 |
+
</div>
|
| 644 |
+
<div class="pane-gauges"></div>
|
| 645 |
+
`;
|
| 646 |
+
|
| 647 |
+
pane.containerEl = container;
|
| 648 |
+
pane.chartEl = container.querySelector('.pane-chart');
|
| 649 |
+
pane.loaderEl = container.querySelector('.pane-loader');
|
| 650 |
+
pane.gaugesEl = container.querySelector('.pane-gauges');
|
| 651 |
+
pane.paneHeaderEl = container.querySelector('.pane-header-mini');
|
| 652 |
+
pane.priceEl = container.querySelector('.pane-price');
|
| 653 |
+
|
| 654 |
+
// Click to activate
|
| 655 |
+
container.addEventListener('click', () => {
|
| 656 |
+
this.setActivePane(pane.paneId);
|
| 657 |
+
});
|
| 658 |
+
|
| 659 |
+
return container;
|
| 660 |
+
},
|
| 661 |
+
|
| 662 |
+
/* ββ Render all panes into grid ββββββββββββββ */
|
| 663 |
+
renderGrid() {
|
| 664 |
+
if (!this._gridEl) return;
|
| 665 |
+
|
| 666 |
+
// Clear grid
|
| 667 |
+
this._gridEl.innerHTML = '';
|
| 668 |
+
|
| 669 |
+
// Set layout class
|
| 670 |
+
LAYOUT_PRESETS.forEach(lp => this._gridEl.classList.remove(`layout-${lp}`));
|
| 671 |
+
this._gridEl.classList.add(`layout-${this.layoutPreset}`);
|
| 672 |
+
|
| 673 |
+
// Build pane DOMs
|
| 674 |
+
for (const [, pane] of this.panes) {
|
| 675 |
+
const el = this.buildPaneDOM(pane);
|
| 676 |
+
this._gridEl.appendChild(el);
|
| 677 |
+
}
|
| 678 |
+
},
|
| 679 |
+
|
| 680 |
+
/* ββ Update pane header info βββββββββββββββββ */
|
| 681 |
+
updatePaneHeader(paneId, symbol, interval, price) {
|
| 682 |
+
const pane = this.panes.get(paneId);
|
| 683 |
+
if (!pane || !pane.paneHeaderEl) return;
|
| 684 |
+
const symEl = pane.paneHeaderEl.querySelector('.pane-symbol');
|
| 685 |
+
const intEl = pane.paneHeaderEl.querySelector('.pane-interval');
|
| 686 |
+
if (symEl) symEl.textContent = symbol || pane.symbol;
|
| 687 |
+
if (intEl) intEl.textContent = interval || pane.interval;
|
| 688 |
+
if (price !== undefined && pane.priceEl) {
|
| 689 |
+
pane.priceEl.textContent = price;
|
| 690 |
+
}
|
| 691 |
+
},
|
| 692 |
+
|
| 693 |
+
/* ββ Utility βββββββββββββββββββββββββββββββββ */
|
| 694 |
+
getAllPaneIds() {
|
| 695 |
+
return Array.from(this.panes.keys());
|
| 696 |
+
},
|
| 697 |
+
|
| 698 |
+
getPaneCount() {
|
| 699 |
+
return this.panes.size;
|
| 700 |
+
}
|
| 701 |
+
};
|
| 702 |
+
|
| 703 |
+
/* ββ Expose to global scope βββββββββββββββοΏ½οΏ½οΏ½βββββ */
|
| 704 |
+
window.PaneState = PaneState;
|
| 705 |
+
window.StreamManager = StreamManager;
|
| 706 |
+
window.DataCoordinator = DataCoordinator;
|
| 707 |
+
window.Workspace = Workspace;
|
| 708 |
+
window.LAYOUT_PRESETS = LAYOUT_PRESETS;
|
| 709 |
+
window.LAYOUT_GRID_MAP = LAYOUT_GRID_MAP;
|
| 710 |
+
|
| 711 |
+
PaneState.prototype.renderGauges = function renderPaneGaugeOverride() {
|
| 712 |
+
if (typeof window.renderPaneCompactGauges === 'function') {
|
| 713 |
+
window.renderPaneCompactGauges(this);
|
| 714 |
+
return;
|
| 715 |
+
}
|
| 716 |
+
if (!this.gaugesEl) {
|
| 717 |
+
return;
|
| 718 |
+
}
|
| 719 |
+
if (!this.lastAnalysis || !this.lastAnalysis.payload || !this.lastAnalysis.payload.analysis) {
|
| 720 |
+
this.gaugesEl.innerHTML = '';
|
| 721 |
+
return;
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
const analysis = this.lastAnalysis.payload.analysis;
|
| 725 |
+
const technical = analysis.dashboard?.technical || analysis.technicals || {};
|
| 726 |
+
const ai = analysis.dashboard?.ai || analysis.ai_gauge || {};
|
| 727 |
+
const summary = analysis.dashboard?.summary || analysis.summary || {};
|
| 728 |
+
const verdict = this.lastAnalysis.payload.verdict || summary.signal || '--';
|
| 729 |
+
const trendScore = Number(technical.score ?? technical.trend_score ?? 50);
|
| 730 |
+
const strengthScore = Number(summary.confidence ?? summary.strength_score ?? ai.score ?? 50);
|
| 731 |
+
const aiScore = Number(ai.score ?? ai.confidence ?? summary.ai_score ?? 50);
|
| 732 |
+
const tone = (score) => score >= 60 ? 'bull' : score <= 40 ? 'bear' : 'flat';
|
| 733 |
+
|
| 734 |
+
this.gaugesEl.innerHTML = `
|
| 735 |
+
<div class="pane-gauge-chip" data-tone="${tone(trendScore)}"><span>T</span><b>${Math.round(trendScore)}</b></div>
|
| 736 |
+
<div class="pane-gauge-chip" data-tone="${tone(strengthScore)}"><span>S</span><b>${Math.round(strengthScore)}</b></div>
|
| 737 |
+
<div class="pane-gauge-chip" data-tone="${tone(aiScore)}"><span>AI</span><b>${Math.round(aiScore)}</b></div>
|
| 738 |
+
<div class="pane-gauge-verdict">${String(verdict).replace(/_/g, ' ')}</div>
|
| 739 |
+
`;
|
| 740 |
+
};
|
| 741 |
+
|
| 742 |
+
Workspace.setActivePane = function setActivePaneOverride(id) {
|
| 743 |
+
if (!this.panes.has(id)) return;
|
| 744 |
+
const prevId = this.activePaneId;
|
| 745 |
+
this.activePaneId = id;
|
| 746 |
+
|
| 747 |
+
if (this._gridEl) {
|
| 748 |
+
this._gridEl.querySelectorAll('.chart-pane').forEach((el) => {
|
| 749 |
+
el.classList.toggle('active', el.dataset.paneId === id);
|
| 750 |
+
});
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
if (prevId !== id && this._onActivePaneChange) {
|
| 754 |
+
this._onActivePaneChange(id);
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
this.panes.forEach((pane) => {
|
| 758 |
+
if (typeof pane.renderGauges === 'function') {
|
| 759 |
+
pane.renderGauges();
|
| 760 |
+
}
|
| 761 |
+
if (typeof window.renderPaneAnalysisUI === 'function') {
|
| 762 |
+
window.renderPaneAnalysisUI(pane);
|
| 763 |
+
}
|
| 764 |
+
});
|
| 765 |
+
};
|
run.bat
CHANGED
|
@@ -6,18 +6,7 @@ echo ====================================================
|
|
| 6 |
echo KRONOS AI TRADING TERMINAL - STARTUP
|
| 7 |
echo ====================================================
|
| 8 |
|
| 9 |
-
|
| 10 |
-
set PORT=7860
|
| 11 |
-
|
| 12 |
-
:: Check if port is already in use and try to free it
|
| 13 |
-
echo [1/3] Checking port %PORT%...
|
| 14 |
-
for /f "tokens=5" %%a in ('netstat -aon ^| findstr :%PORT% ^| findstr LISTENING') do (
|
| 15 |
-
if not "%%a"=="" (
|
| 16 |
-
echo [INFO] Port %PORT% is in use by PID %%a. Freeing port...
|
| 17 |
-
taskkill /F /PID %%a >nul 2>&1
|
| 18 |
-
timeout /t 2 >nul
|
| 19 |
-
)
|
| 20 |
-
)
|
| 21 |
|
| 22 |
:: Check if virtual environment exists
|
| 23 |
if not exist "venv\" (
|
|
@@ -34,11 +23,8 @@ echo [3/3] Launching AI Trading Terminal...
|
|
| 34 |
echo [INFO] Application will open in your browser automatically.
|
| 35 |
echo [INFO] Press CTRL+C in this window to stop the server.
|
| 36 |
|
| 37 |
-
::
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
:: Run the app
|
| 41 |
-
python app.py
|
| 42 |
|
| 43 |
if %ERRORLEVEL% neq 0 (
|
| 44 |
echo.
|
|
|
|
| 6 |
echo KRONOS AI TRADING TERMINAL - STARTUP
|
| 7 |
echo ====================================================
|
| 8 |
|
| 9 |
+
echo [1/3] Preparing launcher...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
:: Check if virtual environment exists
|
| 12 |
if not exist "venv\" (
|
|
|
|
| 23 |
echo [INFO] Application will open in your browser automatically.
|
| 24 |
echo [INFO] Press CTRL+C in this window to stop the server.
|
| 25 |
|
| 26 |
+
:: Run the app with dynamic port selection
|
| 27 |
+
python -m backend.launcher
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
if %ERRORLEVEL% neq 0 (
|
| 30 |
echo.
|