Thang6822 commited on
Commit
43b0e93
Β·
1 Parent(s): b9be111

Deploy OHLC4 forecast line and Hugging Face runtime fixes

Browse files
.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 `http://127.0.0.1:8000`
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 uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload
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
- # Hugging Face Spaces yΓͺu cαΊ§u α»©ng dα»₯ng phαΊ£i chαΊ‘y trΓͺn cα»•ng 7860
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 sys
 
 
2
  import os
 
 
 
3
  import time
4
  import webbrowser
5
- import threading
 
 
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("super-ai-launcher")
 
 
 
 
15
 
16
- # ASCII Art Header
17
  BANNER = r"""
18
- ____ _ _ ____ _____ ____ _ ___ _ _ _ _ _ __ ______ ___ ____
19
- / ___|| | | | _ \| ____| _ \ / \ |_ _| / \ | \ | | / \ | | \ \ / / ___|_ _/ ___|
20
- \___ \| | | | |_) | _| | |_) | / _ \ | | / _ \ | \| | / _ \ | | \ V /\___ \ | \___ \
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
- def print_banner():
30
- # Clear console for a clean startup
31
- os.system('cls' if os.name == 'nt' else 'clear')
 
32
  print("\033[96m" + BANNER + "\033[0m")
33
- print(" [*] Đang khởi Δ‘α»™ng hệ thα»‘ng phΓ’n tΓ­ch AI...")
34
- print(" [*] Đang nαΊ‘p cΖ‘ sở dα»― liệu thα»‹ trường...")
35
- print(" [*] Hệ thα»‘ng sαΊ½ tα»± Δ‘α»™ng mở trΓ¬nh duyệt sau khi hoΓ n tαΊ₯t.")
36
- print(" ══════════════════════════════════════════════════════════════════════════════════════════════\n")
37
-
38
- # Import the FastAPI app
39
- try:
40
- from main import app
41
- except ImportError:
42
- # If running from within the backend folder
43
- sys.path.append(os.path.dirname(__file__))
44
- from main import app
45
-
46
- def open_browser():
47
- """Wait for the server to start and then open the browser."""
48
- # Give the server a few seconds to initialize
49
- time.sleep(2.5)
50
- url = "http://127.0.0.1:8000"
51
- logger.info(f"KαΊΏt nα»‘i thΓ nh cΓ΄ng! Đang mở bαΊ£ng Δ‘iều khiển tαΊ‘i {url}...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  webbrowser.open(url)
53
 
 
54
  if __name__ == "__main__":
55
  print_banner()
56
-
57
- # Start the browser opener in a separate thread
58
- threading.Thread(target=open_browser, daemon=True).start()
59
-
60
- # Run the uvicorn server
61
- logger.info("Khởi Δ‘α»™ng Backend Core...")
 
 
 
62
  try:
63
- # Hide the detailed uvicorn logs for a cleaner professional feel,
64
- # or keep them for transparency. Let's use 'warning' level for uvicorn
65
- # but keep our own logs at 'info'.
66
- uvicorn.run(app, host="127.0.0.1", port=8000, log_level="warning")
67
- except Exception as e:
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": ["binance", "twelvedata", "finnhub", "yfinance"],
 
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=0.1, capacity=2), # 8/min free = ~0.13/s
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","CαΊ·p tiền",{"twelvedata":"DXY","yfinance":"DX-Y.NYB"}),
701
- "EURUSD": _s("EURUSD","EUR/USD","EUR/USD","Cặp tiền",{"binance":"EURUSDT","twelvedata":"EUR/USD","yfinance":"EURUSD=X"}),
702
- "GBPUSD": _s("GBPUSD","GBP/USD","GBP/USD","Cặp tiền",{"binance":"GBPUSDT","twelvedata":"GBP/USD","yfinance":"GBPUSD=X"}),
703
- "USDJPY": _s("USDJPY","USD/JPY","USD/JPY","Cặp tiền",{"binance":"JPYUSDT","twelvedata":"USD/JPY","yfinance":"JPY=X"}),
 
 
 
 
 
 
 
 
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",{"binance":"AUDUSDT","twelvedata":"AUD/USD","yfinance":"AUDUSD=X"}),
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
- "EURJPY": _s("EURJPY","EUR/JPY","EUR/JPY","Cặp tiền",{"binance":"EURJPY","twelvedata":"EUR/JPY"}),
710
- "EURGBP": _s("EURGBP","EUR/GBP","EUR/GBP","Cặp tiền",{"binance":"EURGBP","twelvedata":"EUR/GBP"}),
 
 
 
 
 
 
 
 
 
 
 
 
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 300
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": TWELVEDATA_API_KEY,
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
- raise HTTPException(status_code=429, detail="TwelveData rate limit")
 
 
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 "code" in payload and payload.get("code") in [429, 400]:
 
 
 
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": FINNHUB_API_KEY,
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
- raise
 
 
 
 
 
 
 
 
 
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
- cached = ticker_cache.get(f"ticker:{symbol}")
 
 
1434
  if cached:
1435
  return cached
1436
 
1437
- inflight_key = f"ticker:{symbol}"
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": TWELVEDATA_API_KEY},
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 ("C?p ti?n", "Ch? s?") else 30)
1553
- ticker_cache.set(f"ticker:{symbol}", res, ttl_seconds=ttl)
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 _safe_volume(df: pd.DataFrame) -> np.ndarray:
3802
- vol = df["volume"].values.astype(np.float32)
3803
- if vol.sum() == 0:
3804
- typical = ((df["high"] + df["low"] + df["close"]) / 3).values.astype(np.float32)
3805
- vol = np.full_like(typical, typical.mean())
3806
- return vol
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- vol = self._safe_volume(df)
3820
- if "amount" in df.columns and df["amount"].sum() != 0:
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 = np.mean(x, axis=0)
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
- samples = await asyncio.to_thread(
3843
- self._predictor.generate,
3844
- x=x_norm, x_stamp=x_stamp, y_stamp=y_stamp,
3845
- pred_len=horizon, T=1.0, top_k=0, top_p=0.9,
3846
- sample_count=sample_count, verbose=False, return_samples=True,
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
- close_samples = np.asarray(samples[0, :, :, 3], dtype=float)
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 close_samples.shape[-1] > horizon:
3859
- close_samples = close_samples[:, -horizon:]
3860
- elif close_samples.shape[-1] < horizon:
3861
- pad_width = horizon - close_samples.shape[-1]
3862
- close_samples = np.pad(close_samples, ((0, 0), (0, pad_width)), mode="edge")
3863
 
3864
- close_samples = close_samples * x_std_safe[3] + x_mean[3]
 
 
 
3865
 
3866
  return {
3867
- "p10": np.percentile(close_samples, 10, axis=0),
3868
- "p50": np.percentile(close_samples, 50, axis=0),
3869
- "p90": np.percentile(close_samples, 90, axis=0),
3870
  "model_name": self.MODEL_NAME,
3871
  "context_length": len(df),
3872
- "output_horizon": int(close_samples.shape[-1]),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 get_ticker(symbol)
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
- prefix = _cache_prefix(symbol, interval)
4420
- cache_key = f"forecast_{prefix}{horizon}"
4421
  cache_origin = "live"
4422
 
4423
  if not refresh:
4424
- cached = forecast_cache.get(cache_key)
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
- data_list, source, indicators = await get_indicators_cached(
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
- data=data_list,
4525
- indicators=indicators,
4526
- forecast_rows=forecast_rows,
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
- headers = {
4854
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
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

  • SHA256: e39bcdb430c525cccefa819945061c629f4d510922721fbc6ac8298e2d46e9d3
  • Pointer size: 133 Bytes
  • Size of remote file: 11.4 MB
frontend/index.html CHANGED
@@ -1,4 +1,4 @@
1
- ο»Ώ<!doctype html>
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
- <!-- Chart canvas -->
3363
- <div id="chart"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- : 'http://127.0.0.1:8000';
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 nhαΊ‘t
3844
- const color = diff > EPSILON ? '#10b981' : diff < -EPSILON ? '#f43f5e' : '#fef08a';
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 || !payload?.analysis) {
4151
- if (container) {
4152
- container.innerHTML = '';
4153
- container.classList.remove('combo-active');
4154
- }
 
 
 
 
 
 
 
 
 
 
4155
  return;
4156
  }
4157
 
@@ -4419,12 +4660,14 @@
4419
  }
4420
 
4421
  function setPrimaryForecastVisibility(visible) {
4422
- p50Series.applyOptions({ visible: false });
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 commitForecastVisuals(symbol, interval, p50, p10, p90) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4437
  p50Series.setData([]);
4438
- p10Series.setData(p10);
4439
- p90Series.setData(p90);
4440
- setPrimaryForecastVisibility(true);
4441
- buildForecastSegmentSeries(p50);
 
 
 
 
 
 
 
 
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 (type === 'bb' || type === 'both') {
4577
- if (series.bb_upper) bbUpperSeries.setData(series.bb_upper);
4578
- if (series.bb_mid) bbMiddleSeries.setData(series.bb_mid);
4579
- if (series.bb_lower) bbLowerSeries.setData(series.bb_lower);
4580
- }
4581
 
4582
- chart.timeScale().fitContent();
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 hasForecast = Array.isArray(fData.forecast) && fData.forecast.length > 0;
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
- const forecastPoints = fData.forecast;
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 lastForecastVal = forecastPoints[forecastPoints.length - 1]?.p50 ?? anchorPoint.value;
4687
- const isBull = lastForecastVal >= anchorPoint.value;
 
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
- timeframeSelect.onchange = () => refreshChart({ forceContextReset: true });
4827
- indicatorSelect.onchange = () => refreshChart();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- fitBtn.onclick = () => chart.timeScale().fitContent();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // Default start
5018
- await switchSymbol('XAUUSD');
 
5019
 
5020
  // Setup auto-refresh (P1-10)
5021
  let autoRefreshTimer = null;
5022
  function scheduleAutoRefresh() {
5023
  if (autoRefreshTimer) clearTimeout(autoRefreshTimer);
5024
 
5025
- // Interval logic: 1m for 1m/5m, 5m for 15m/1h, etc.
5026
  const intv = timeframeSelect.value;
5027
- let delay = 300000; // 5m default
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
- await refreshChart();
 
 
 
 
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
- :: Port to check
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
- :: Open browser in background
38
- start http://localhost:%PORT%
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.