Thang6822 commited on
Commit
dbfd1de
·
1 Parent(s): c2c0884

Update backend and frontend: Syncing latest changes to HuggingFace

Browse files
Files changed (4) hide show
  1. .gitignore +4 -0
  2. backend/main.py +810 -181
  3. frontend/Light_BG.png +2 -2
  4. frontend/index.html +1170 -155
.gitignore CHANGED
@@ -6,3 +6,7 @@ __pycache__/
6
  *.db
7
  .tmp.*/
8
  .vscode/
 
 
 
 
 
6
  *.db
7
  .tmp.*/
8
  .vscode/
9
+ build/
10
+ dist/
11
+ scratch/
12
+ *.spec
backend/main.py CHANGED
@@ -1175,7 +1175,7 @@ def _get_source_priority(symbol: str) -> List[str]:
1175
 
1176
 
1177
  async def fetch_historical(
1178
- symbol: str, interval: str, limit: int
1179
  ) -> Tuple[List[Dict[str, Any]], str]:
1180
  """
1181
  Fetch OHLCV data with fallback and caching.
@@ -1183,15 +1183,18 @@ async def fetch_historical(
1183
  """
1184
  prefix = _cache_prefix(symbol, interval)
1185
  key = f"hist_{prefix}" # BUG-P1-03: No limit in key to increase cache hits
1186
- cached = historical_cache.get(key)
1187
- if cached is not None:
1188
- try:
1189
- # v6.1: Cache stores (data, source)
1190
- data_cached, source_cached = cached
1191
- return data_cached[-limit:], source_cached
1192
- except (ValueError, TypeError):
1193
- # Handle old cache format gracefully
1194
- historical_cache.delete(key)
 
 
 
1195
 
1196
  priority = _get_source_priority(symbol)
1197
  errors: List[str] = []
@@ -1357,9 +1360,12 @@ def _rsi(close: np.ndarray, period: int = 14) -> np.ndarray:
1357
 
1358
  avg_gain = pd.Series(gain).ewm(alpha=1.0/period, adjust=False).mean()
1359
  avg_loss = pd.Series(loss).ewm(alpha=1.0/period, adjust=False).mean()
1360
-
1361
- rs = avg_gain / avg_loss.replace(0, np.inf)
1362
  rsi = 100 - (100 / (1 + rs))
 
 
 
1363
  # Prepend NaN to match original array length
1364
  return np.concatenate([[np.nan], rsi.values])
1365
 
@@ -1367,7 +1373,7 @@ def _bollinger(close: np.ndarray, period=20, k=2.0) -> Tuple[np.ndarray, np.ndar
1367
  """Vectorized Bollinger Bands."""
1368
  s = pd.Series(close)
1369
  mid = s.rolling(window=period).mean()
1370
- std = s.rolling(window=period).std()
1371
  return (mid + k*std).values, mid.values, (mid - k*std).values
1372
 
1373
 
@@ -1452,6 +1458,62 @@ def _momentum(close: np.ndarray, period: int = 10) -> np.ndarray:
1452
  return pd.Series(close).diff(period).values
1453
 
1454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1455
  def _williams_r(high: np.ndarray, low: np.ndarray, close: np.ndarray,
1456
  period: int = 14) -> np.ndarray:
1457
  """Vectorized Williams %R."""
@@ -1507,14 +1569,25 @@ def _vwma(close: np.ndarray, volume: np.ndarray, period: int = 20) -> np.ndarray
1507
  return (cv.rolling(period).sum() / v.rolling(period).sum().replace(0, np.inf)).values
1508
 
1509
 
 
 
 
 
 
 
 
 
 
 
 
1510
  def _hull_ma(close: np.ndarray, period: int = 9) -> np.ndarray:
1511
  """Hull Moving Average."""
1512
  half = max(period // 2, 1)
1513
  sqrt_p = max(int(math.sqrt(period)), 1)
1514
- wma_half = _sma(close, half)
1515
- wma_full = _sma(close, period)
1516
  diff = 2 * wma_half - wma_full
1517
- hull = _sma(np.where(np.isnan(diff), close, diff), sqrt_p)
1518
  return hull
1519
 
1520
 
@@ -1607,6 +1680,20 @@ def _osc_action(name: str, value: float, **kw) -> str:
1607
  """Classify oscillator value as 'Mua' / 'Bán' / 'Trung lập'."""
1608
  if value is None or math.isnan(value):
1609
  return "Trung lập"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1610
  if name == "rsi":
1611
  return "Bán" if value > 70 else "Mua" if value < 30 else "Trung lập"
1612
  if name == "stoch":
@@ -1644,6 +1731,49 @@ def _ma_action(price: float, ma_val: float) -> str:
1644
  return "Mua" if price > ma_val else "Bán"
1645
 
1646
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1647
  def compute_indicators(data: List[Dict[str, Any]]) -> Dict[str, Any]:
1648
  """Compute a full suite of technical indicators on OHLCV data."""
1649
  if len(data) < 30:
@@ -1677,6 +1807,13 @@ def compute_indicators(data: List[Dict[str, Any]]) -> Dict[str, Any]:
1677
  bb_u, bb_m, bb_l = _bollinger(closes)
1678
  atr14 = _atr(highs, lows, closes, 14)
1679
  stoch_k, stoch_d = _stoch_rsi(closes)
 
 
 
 
 
 
 
1680
 
1681
  # Volume SMA 20 (Vectorized v6.0)
1682
  vol_sma = _sma(vols, 20)
@@ -1726,6 +1863,34 @@ def compute_indicators(data: List[Dict[str, Any]]) -> Dict[str, Any]:
1726
  else "neutral"
1727
  ),
1728
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1729
  "volume": {
1730
  "last": round(float(vols[-1]), 2),
1731
  "sma20": round(float(vol_sma[-1]), 2),
@@ -1881,26 +2046,24 @@ def _blend_forecasts(
1881
 
1882
  bias_pct = abs((scale - 1.0) * 100.0)
1883
 
1884
- # Refined confidence score
1885
- confidence = 60.0
1886
- confidence += 15.0 if agreement else -10.0
1887
-
1888
- # Trend alignment (Bull/Bear stack)
1889
- if indicators["trend"].get("ema_bullish_stack") and model_dir > 0:
1890
- confidence += 10.0
1891
- elif not indicators["trend"].get("ema_bullish_stack") and model_dir < 0:
1892
- # Bearish stack + down forecast
1893
- confidence += 5.0
1894
-
1895
- # Penalty for high uncertainty (wide bands)
1896
- confidence -= min(25.0, band_width_pct * 150.0)
1897
- # Penalty for extreme bias/scaling corrections
1898
- confidence -= min(15.0, bias_pct * 0.5)
1899
- # Penalty for low volume relative to average
1900
- if not indicators["volume"].get("above_avg"):
1901
- confidence -= 5.0
1902
-
1903
- confidence = _clamp(confidence, 10.0, 95.0)
1904
 
1905
  return {
1906
  "p10": blend_p10,
@@ -2019,7 +2182,7 @@ def _build_signals(
2019
  signals.append(Signal("ai_forecast", _clamp(forecast_return_pct / 3.0, -1, 1) * (confidence/100.0), 2.0, f"AI {forecast_return_pct:+.2f}%"))
2020
 
2021
  # ── 11: EMA Cross (9 vs 21) ──
2022
- signals.append(Signal("ema_cross_9_21", 1.0 if ema9 > ema21 else -1.0, 1.2, f"EMA9 {'>' if ema9>ema21 else '<'} EMA21"))
2023
 
2024
  # ── 12: RSI Extremes ──
2025
  rsi_ext = 1.0 if rsi < 20 else -1.0 if rsi > 80 else 0.0
@@ -2377,17 +2540,490 @@ def _build_reasoning(
2377
  return reasons, warnings, opportunities
2378
 
2379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2380
  def _build_trade_analysis(
2381
  symbol: str, interval: str, data: List[Dict[str, Any]], indicators: Dict[str, Any],
2382
  forecast_rows: List[Dict[str, Any]], confidence: float, source: str,
 
2383
  ) -> Dict[str, Any]:
2384
  """
2385
- TradingView-style technical analysis dashboard.
2386
- Returns pure numerical data: oscillators, moving averages, pivot points.
2387
- No trade setups, no entry/SL/TP, no reasoning text.
2388
  """
2389
  if not data or len(data) < 30:
2390
- return {"oscillators": {"data": []}, "moving_averages": {"data": []}, "pivot_points": {"data": []}, "summary": {"signal": "Trung lập", "sell": 0, "neutral": 0, "buy": 0}}
2391
 
2392
  closes = np.array([float(d["close"]) for d in data], dtype=float)
2393
  highs = np.array([float(d["high"]) for d in data], dtype=float)
@@ -2396,151 +3032,117 @@ def _build_trade_analysis(
2396
  last_close = closes[-1]
2397
 
2398
  def _lv(arr):
2399
- if isinstance(arr, pd.Series):
2400
- arr = arr.values
2401
  v = arr[-1] if len(arr) else float('nan')
2402
  return None if (v is None or (isinstance(v, float) and math.isnan(v))) else round(float(v), 2)
2403
 
2404
- # ── Compute all oscillators ──
2405
- rsi14 = _rsi(closes, 14)
2406
- stoch_k_arr, stoch_d_arr = _stoch_rsi(closes, 14, 14, 3, 3)
2407
- cci20 = _cci(highs, lows, closes, 20)
2408
- adx14, plus_di14, minus_di14 = _adx(highs, lows, closes, 14)
2409
- ao = _awesome_oscillator(highs, lows)
2410
- mom10 = _momentum(closes, 10)
2411
- macd_l, macd_s, macd_h = _macd(closes, 12, 26, 9)
2412
- stoch_rsi_k, stoch_rsi_d = _stoch_rsi(closes, 14, 14, 3, 3)
2413
- wr14 = _williams_r(highs, lows, closes, 14)
2414
- bbp = _bull_bear_power(highs, lows, closes, 13)
2415
- uo = _ultimate_oscillator(highs, lows, closes, 7, 14, 28)
2416
-
2417
  osc_data = []
2418
- osc_buy = osc_sell = osc_neutral = 0
2419
-
2420
  def _add_osc(label, val, action_name, **kw):
2421
- nonlocal osc_buy, osc_sell, osc_neutral
2422
- # Handle scalar, Series or ndarray
2423
- if isinstance(val, (np.ndarray, pd.Series, list)):
2424
- v = _lv(val)
2425
- else:
2426
- v = round(float(val), 2) if val is not None else None
2427
  act = _osc_action(action_name, v if v is not None else 0, **kw)
2428
- osc_data.append({"name": label, "value": v, "action": act})
2429
- if act == "Mua": osc_buy += 1
2430
- elif act == "Bán": osc_sell += 1
2431
- else: osc_neutral += 1
2432
-
2433
- _add_osc("Chỉ số Sức mạnh tương đối (14)", rsi14, "rsi")
2434
- _add_osc("Stochastic %K (14, 3, 3)", stoch_k_arr, "stoch")
2435
- _add_osc("Chỉ số Kênh hàng hóa (20)", cci20, "cci")
2436
- _add_osc("Chỉ số Định hướng Trung bình (14)", adx14, "adx",
2437
- plus_di=_lv(plus_di14) or 0, minus_di=_lv(minus_di14) or 0)
2438
- _add_osc("Chỉ số Dao động AO", ao, "ao")
2439
- _add_osc("Xung lượng (10)", mom10, "momentum")
2440
- _add_osc("Cấp độ MACD (12, 26)", macd_l, "macd", signal=_lv(macd_s) or 0)
2441
- _add_osc("Đường RSI Nhanh (3, 3, 14, 14)", stoch_rsi_k, "stoch_rsi")
2442
- _add_osc("Vùng Phần trăm Williams (14)", wr14, "williams")
2443
- _add_osc("Sức Mạnh Giá Lên Giá Xuống", bbp, "bbp")
2444
- _add_osc("Dao động Ultimate (7, 14, 28)", uo, "ultimate")
2445
-
2446
- osc_total = osc_buy + osc_sell + osc_neutral
2447
- if osc_buy > osc_sell + 2:
2448
- osc_signal = "Mua"
2449
- elif osc_sell > osc_buy + 2:
2450
- osc_signal = "Bán"
2451
- else:
2452
- osc_signal = "Trung lập"
2453
-
2454
- # ── Compute all Moving Averages ──
2455
  ma_data = []
2456
- ma_buy = ma_sell = ma_neutral = 0
2457
-
2458
  def _add_ma(label, val_arr):
2459
- nonlocal ma_buy, ma_sell, ma_neutral
2460
  v = _lv(val_arr)
2461
  act = _ma_action(last_close, v if v is not None else last_close)
2462
  ma_data.append({"name": label, "value": v, "action": act})
2463
- if act == "Mua": ma_buy += 1
2464
- elif act == "Bán": ma_sell += 1
2465
- else: ma_neutral += 1
2466
 
2467
- # EMA periods
2468
  for p in [10, 20, 30, 50, 100, 200]:
2469
  _add_ma(f"Trung bình Trượt Hàm mũ ({p})", _ema(closes, p))
2470
  _add_ma(f"Đường Trung bình trượt Đơn giản ({p})", _sma(closes, p))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2471
 
2472
- # Ichimoku
2473
- ichi = _ichimoku_base(highs, lows, 26)
2474
- _add_ma("Đường sở Ichimoku (9, 26, 52, 26)", ichi)
2475
-
2476
- # VWMA
2477
- vwma_arr = _vwma(closes, vols, 20)
2478
- _add_ma("Đường Trung bình di động Tỷ trọng tuyến tính (20)", vwma_arr)
2479
-
2480
- # Hull MA
2481
- hull = _hull_ma(closes, 9)
2482
- _add_ma("Đường trung bình trượt Hull (9)", hull)
2483
-
2484
- if ma_buy > ma_sell + 2:
2485
- ma_signal = "Mua"
2486
- elif ma_sell > ma_buy + 2:
2487
- ma_signal = "Bán"
2488
- else:
2489
- ma_signal = "Trung lập"
2490
-
2491
- # Summary (Total) ──
2492
- total_buy = osc_buy + ma_buy
2493
- total_sell = osc_sell + ma_sell
2494
- total_neutral = osc_neutral + ma_neutral
2495
-
2496
- # Derive bias from signal
2497
- if total_buy > total_sell + 3:
2498
- summary_bias = "bullish"
2499
- elif total_sell > total_buy + 3:
2500
- summary_bias = "bearish"
2501
- else:
2502
- summary_bias = "neutral"
2503
-
2504
- # B-8: Integrated Summary Signals (v6.0)
2505
- # Combine TV style with ensemble confidence
2506
- if total_buy > total_sell + 6 and confidence > 65:
2507
- total_signal = "Mua mạnh (Cực độ)"
2508
- elif total_buy > total_sell + 3 and confidence > 55:
2509
- total_signal = "Mua"
2510
- elif total_sell > total_buy + 6 and confidence > 65:
2511
- total_signal = "Bán mạnh (Cực độ)"
2512
- elif total_sell > total_buy + 3 and confidence > 55:
2513
- total_signal = "Bán"
2514
- else:
2515
- total_signal = "Trung lập (Thận trọng)"
2516
 
2517
- # B-4: Use last COMPLETED candle for pivot calculation to avoid flickering
2518
  last_h = float(highs[-2]) if len(highs) > 1 else float(highs[-1])
2519
  last_l = float(lows[-2]) if len(lows) > 1 else float(lows[-1])
2520
  last_c = float(closes[-2]) if len(closes) > 1 else float(closes[-1])
2521
  pivots = _calc_pivot_points(last_h, last_l, last_c)
 
2522
 
2523
  return {
2524
  "style": "tradingview",
2525
- "summary": {
2526
- "sell": total_sell, "neutral": total_neutral, "buy": total_buy,
2527
- "signal": total_signal,
2528
- "bias": summary_bias,
2529
- },
2530
  "oscillators": {
2531
- "sell": osc_sell, "neutral": osc_neutral, "buy": osc_buy,
2532
- "signal": osc_signal,
2533
- "data": osc_data,
 
 
 
2534
  },
2535
  "moving_averages": {
2536
- "sell": ma_sell, "neutral": ma_neutral, "buy": ma_buy,
2537
- "signal": ma_signal,
2538
- "data": ma_data,
 
 
 
 
 
2539
  },
2540
- "pivot_points": pivots,
 
2541
  }
2542
 
2543
 
 
2544
  START_TIME = time.time()
2545
 
2546
  async def _background_cleanup():
@@ -3149,15 +3751,18 @@ async def get_historical(
3149
  symbol: str,
3150
  interval: str = Query("1h"),
3151
  limit: int = Query(500, ge=50, le=2000),
 
3152
  ) -> Dict[str, Any]:
3153
  symbol = _get_canonical_symbol(symbol)
3154
  if symbol not in SYMBOLS:
3155
  raise HTTPException(404, f"Unknown symbol: {symbol}")
3156
  if interval not in SUPPORTED_INTERVALS:
3157
  raise HTTPException(400, f"Unsupported interval: {interval}")
3158
- data, source = await fetch_historical(symbol, interval, limit)
3159
  return {"symbol": symbol, "interval": interval, "source": source,
3160
- "count": len(data), "data": data}
 
 
3161
 
3162
 
3163
  # ── Technical Indicators ──────────────────────────────────────────────────────
@@ -3166,6 +3771,7 @@ async def get_indicators(
3166
  symbol: str,
3167
  interval: str = Query("1h"),
3168
  limit: int = Query(300, ge=50, le=1000),
 
3169
  ) -> Dict[str, Any]:
3170
  symbol = _get_canonical_symbol(symbol)
3171
  if symbol not in SYMBOLS:
@@ -3173,13 +3779,15 @@ async def get_indicators(
3173
  if interval not in SUPPORTED_INTERVALS:
3174
  raise HTTPException(400, f"Unsupported interval: {interval}")
3175
 
3176
- data, source = await fetch_historical(symbol, interval, limit)
3177
  indicators = compute_indicators(data)
3178
  return {
3179
  "symbol": symbol,
3180
  "interval": interval,
3181
  "source": source,
3182
  "candles": len(data),
 
 
3183
  "indicators": indicators,
3184
  }
3185
 
@@ -3189,6 +3797,8 @@ async def get_indicators(
3189
  async def get_analysis(
3190
  symbol: str,
3191
  interval: str = Query("1h"),
 
 
3192
  ) -> Dict[str, Any]:
3193
  """
3194
  A-5: Direct access to the comprehensive Analysis Engine.
@@ -3199,7 +3809,7 @@ async def get_analysis(
3199
  raise HTTPException(404, f"Unknown symbol: {symbol}")
3200
 
3201
  # Fetch main context
3202
- data, source = await fetch_historical(symbol, interval, 500)
3203
  if len(data) < 50:
3204
  raise HTTPException(422, "Insufficient data for full analysis")
3205
 
@@ -3208,7 +3818,7 @@ async def get_analysis(
3208
  htf_bias = "neutral"
3209
  if htf_interval:
3210
  try:
3211
- htf_data, _ = await fetch_historical(symbol, htf_interval, 200)
3212
  htf_inds = compute_indicators(htf_data)
3213
  htf_bias = "bullish" if htf_inds["trend"].get("above_ema200") else "bearish"
3214
  except Exception:
@@ -3217,16 +3827,20 @@ async def get_analysis(
3217
  # Compute Indicators
3218
  indicators = compute_indicators(data)
3219
 
3220
- # We need a mock forecast return if no forecast is available
3221
  forecast_ret = 0.0
3222
  confidence = 50.0
 
 
3223
  try:
3224
- # Try to get from cache to avoid heavy re-computation
3225
  f_prefix = _cache_prefix(symbol, interval)
3226
  f_cache = forecast_cache.get(f"forecast_{f_prefix}10")
3227
  if f_cache:
3228
- forecast_ret = _pct(f_cache["forecast"][-1]["p50"], f_cache["last_close"])
3229
- confidence = f_cache["ensemble"]["confidence"]
 
 
 
3230
  except Exception:
3231
  pass
3232
 
@@ -3236,9 +3850,10 @@ async def get_analysis(
3236
  interval=interval,
3237
  data=data,
3238
  indicators=indicators,
3239
- forecast_rows=[], # Optional here
3240
  confidence=confidence,
3241
  source=source,
 
3242
  )
3243
 
3244
  # Inject MTF into analysis
@@ -3255,7 +3870,8 @@ async def get_analysis(
3255
  "timestamp": int(time.time()),
3256
  "analysis": analysis,
3257
  "verdict": await get_gemini_verdict(symbol, analysis, forecast_ret),
3258
- "indicators_snapshot": indicators if Query(False) else None # Save bandwidth
 
3259
  }
3260
 
3261
 
@@ -3320,6 +3936,7 @@ async def get_forecast(
3320
  symbol: str,
3321
  interval: str = Query("1h"),
3322
  horizon: int = Query(10, ge=5, le=300),
 
3323
  ) -> Dict[str, Any]:
3324
  symbol = _get_canonical_symbol(symbol)
3325
  if symbol not in SYMBOLS:
@@ -3329,21 +3946,27 @@ async def get_forecast(
3329
 
3330
  prefix = _cache_prefix(symbol, interval)
3331
  cache_key = f"forecast_{prefix}{horizon}"
 
3332
 
3333
- # L1: RAM Cache
3334
- cached = forecast_cache.get(cache_key)
3335
- if cached is not None:
3336
- return cached
3337
-
3338
- # L2: Persistent SQLite Cache (A-4)
3339
- p_cached = persistent_cache.get(cache_key)
3340
- if p_cached is not None:
3341
- p_cached["from_persistent_cache"] = True
3342
- # Backfill L1
3343
- forecast_cache.set(cache_key, p_cached, ttl_seconds=forecast_ttl(interval))
3344
- return p_cached
 
 
 
 
 
3345
 
3346
- data_list, source = await fetch_historical(symbol, interval, 1500)
3347
  if not KRONOS_AVAILABLE:
3348
  # Return graceful empty forecast so UI doesn't break
3349
  return {
@@ -3421,6 +4044,7 @@ async def get_forecast(
3421
  forecast_rows=forecast_rows,
3422
  confidence=float(blended["confidence"]),
3423
  source=source,
 
3424
  )
3425
 
3426
  response = {
@@ -3447,6 +4071,11 @@ async def get_forecast(
3447
  },
3448
  "indicators_snapshot": indicators,
3449
  "analysis": analysis,
 
 
 
 
 
3450
  }
3451
 
3452
  # L1: RAM
@@ -3600,8 +4229,8 @@ async def fetch_gemini_analysis(prompt: str) -> str:
3600
  # We'll rely on the prompt to enforce this, but can sanitize here
3601
  return text
3602
  return "Không có phản hồi từ AI"
3603
- except Exception as ex:
3604
- logger.error("[Gemini] Exception: %s", ex)
3605
  return "Lỗi phân tích AI"
3606
 
3607
 
 
1175
 
1176
 
1177
  async def fetch_historical(
1178
+ symbol: str, interval: str, limit: int, refresh: bool = False
1179
  ) -> Tuple[List[Dict[str, Any]], str]:
1180
  """
1181
  Fetch OHLCV data with fallback and caching.
 
1183
  """
1184
  prefix = _cache_prefix(symbol, interval)
1185
  key = f"hist_{prefix}" # BUG-P1-03: No limit in key to increase cache hits
1186
+ if refresh:
1187
+ historical_cache.delete(key)
1188
+ else:
1189
+ cached = historical_cache.get(key)
1190
+ if cached is not None:
1191
+ try:
1192
+ # v6.1: Cache stores (data, source)
1193
+ data_cached, source_cached = cached
1194
+ return data_cached[-limit:], source_cached
1195
+ except (ValueError, TypeError):
1196
+ # Handle old cache format gracefully
1197
+ historical_cache.delete(key)
1198
 
1199
  priority = _get_source_priority(symbol)
1200
  errors: List[str] = []
 
1360
 
1361
  avg_gain = pd.Series(gain).ewm(alpha=1.0/period, adjust=False).mean()
1362
  avg_loss = pd.Series(loss).ewm(alpha=1.0/period, adjust=False).mean()
1363
+
1364
+ rs = avg_gain / avg_loss.replace(0, np.nan)
1365
  rsi = 100 - (100 / (1 + rs))
1366
+ rsi = rsi.where(avg_loss > 0, 100.0)
1367
+ rsi = rsi.where(avg_gain > 0, 0.0)
1368
+ rsi = rsi.where(~((avg_gain == 0) & (avg_loss == 0)), 50.0)
1369
  # Prepend NaN to match original array length
1370
  return np.concatenate([[np.nan], rsi.values])
1371
 
 
1373
  """Vectorized Bollinger Bands."""
1374
  s = pd.Series(close)
1375
  mid = s.rolling(window=period).mean()
1376
+ std = s.rolling(window=period).std(ddof=0)
1377
  return (mid + k*std).values, mid.values, (mid - k*std).values
1378
 
1379
 
 
1458
  return pd.Series(close).diff(period).values
1459
 
1460
 
1461
+ def _roc(close: np.ndarray, period: int = 12) -> np.ndarray:
1462
+ """Rate of Change in percentage."""
1463
+ base = pd.Series(close).shift(period)
1464
+ return (pd.Series(close) / base.replace(0, np.nan) - 1.0).mul(100.0).values
1465
+
1466
+
1467
+ def _trix(close: np.ndarray, period: int = 18) -> np.ndarray:
1468
+ """Triple EMA oscillator in percentage."""
1469
+ ema1 = pd.Series(_ema(close, period))
1470
+ ema2 = ema1.ewm(span=period, adjust=False).mean()
1471
+ ema3 = ema2.ewm(span=period, adjust=False).mean()
1472
+ return ema3.pct_change().mul(100.0).values
1473
+
1474
+
1475
+ def _ppo(close: np.ndarray, fast: int = 12, slow: int = 26) -> np.ndarray:
1476
+ """Percentage Price Oscillator."""
1477
+ ema_fast = _ema(close, fast)
1478
+ ema_slow = _ema(close, slow)
1479
+ return ((ema_fast - ema_slow) / np.where(np.abs(ema_slow) < 1e-8, np.nan, ema_slow) * 100.0)
1480
+
1481
+
1482
+ def _cmo(close: np.ndarray, period: int = 14) -> np.ndarray:
1483
+ """Chande Momentum Oscillator."""
1484
+ delta = pd.Series(close).diff()
1485
+ up = delta.clip(lower=0.0).rolling(period).sum()
1486
+ down = (-delta.clip(upper=0.0)).rolling(period).sum()
1487
+ return ((up - down) / (up + down).replace(0, np.nan) * 100.0).values
1488
+
1489
+
1490
+ def _dpo(close: np.ndarray, period: int = 20) -> np.ndarray:
1491
+ """Detrended Price Oscillator."""
1492
+ offset = int(period / 2) + 1
1493
+ sma = pd.Series(close).rolling(period).mean()
1494
+ return (pd.Series(close) - sma.shift(offset)).values
1495
+
1496
+
1497
+ def _aroon_oscillator(high: np.ndarray, low: np.ndarray, period: int = 25) -> np.ndarray:
1498
+ """Aroon Oscillator = Aroon Up - Aroon Down."""
1499
+ hs = pd.Series(high)
1500
+ ls = pd.Series(low)
1501
+ aroon_up = hs.rolling(period).apply(lambda x: ((period - 1 - (len(x) - 1 - int(np.argmax(x)))) / (period - 1)) * 100.0, raw=True)
1502
+ aroon_down = ls.rolling(period).apply(lambda x: ((period - 1 - (len(x) - 1 - int(np.argmin(x)))) / (period - 1)) * 100.0, raw=True)
1503
+ return (aroon_up - aroon_down).values
1504
+
1505
+
1506
+ def _tsi(close: np.ndarray, long_period: int = 25, short_period: int = 13) -> np.ndarray:
1507
+ """True Strength Index."""
1508
+ delta = pd.Series(close).diff()
1509
+ abs_delta = delta.abs()
1510
+ ema1 = delta.ewm(span=long_period, adjust=False).mean()
1511
+ ema2 = ema1.ewm(span=short_period, adjust=False).mean()
1512
+ abs_ema1 = abs_delta.ewm(span=long_period, adjust=False).mean()
1513
+ abs_ema2 = abs_ema1.ewm(span=short_period, adjust=False).mean()
1514
+ return (ema2 / abs_ema2.replace(0, np.nan) * 100.0).values
1515
+
1516
+
1517
  def _williams_r(high: np.ndarray, low: np.ndarray, close: np.ndarray,
1518
  period: int = 14) -> np.ndarray:
1519
  """Vectorized Williams %R."""
 
1569
  return (cv.rolling(period).sum() / v.rolling(period).sum().replace(0, np.inf)).values
1570
 
1571
 
1572
+ def _wma(arr: np.ndarray, period: int) -> np.ndarray:
1573
+ """Weighted moving average used by Hull MA."""
1574
+ if len(arr) == 0:
1575
+ return np.array([], dtype=float)
1576
+ weights = np.arange(1, period + 1, dtype=float)
1577
+ return pd.Series(arr).rolling(window=period).apply(
1578
+ lambda x: float(np.dot(x, weights) / weights.sum()),
1579
+ raw=True,
1580
+ ).values
1581
+
1582
+
1583
  def _hull_ma(close: np.ndarray, period: int = 9) -> np.ndarray:
1584
  """Hull Moving Average."""
1585
  half = max(period // 2, 1)
1586
  sqrt_p = max(int(math.sqrt(period)), 1)
1587
+ wma_half = _wma(close, half)
1588
+ wma_full = _wma(close, period)
1589
  diff = 2 * wma_half - wma_full
1590
+ hull = _wma(np.where(np.isnan(diff), close, diff), sqrt_p)
1591
  return hull
1592
 
1593
 
 
1680
  """Classify oscillator value as 'Mua' / 'Bán' / 'Trung lập'."""
1681
  if value is None or math.isnan(value):
1682
  return "Trung lập"
1683
+ if name == "roc":
1684
+ return "Mua" if value > 1.0 else "Bán" if value < -1.0 else "Trung lập"
1685
+ if name == "trix":
1686
+ return "Mua" if value > 0 else "Bán" if value < 0 else "Trung lập"
1687
+ if name == "ppo":
1688
+ return "Mua" if value > 0.35 else "Bán" if value < -0.35 else "Trung lập"
1689
+ if name == "cmo":
1690
+ return "Mua" if value > 20 else "Bán" if value < -20 else "Trung lập"
1691
+ if name == "dpo":
1692
+ return "Mua" if value > 0 else "Bán" if value < 0 else "Trung lập"
1693
+ if name == "aroon":
1694
+ return "Mua" if value > 25 else "Bán" if value < -25 else "Trung lập"
1695
+ if name == "tsi":
1696
+ return "Mua" if value > 5 else "Bán" if value < -5 else "Trung lập"
1697
  if name == "rsi":
1698
  return "Bán" if value > 70 else "Mua" if value < 30 else "Trung lập"
1699
  if name == "stoch":
 
1731
  return "Mua" if price > ma_val else "Bán"
1732
 
1733
 
1734
+ def _osc_signal_score(name: str, value: float, **kw) -> float:
1735
+ """Continuous oscillator score in [-1, 1] for weighting strength internally."""
1736
+ if value is None or math.isnan(value):
1737
+ return 0.0
1738
+
1739
+ scale = max(float(kw.get("scale", 1.0) or 1.0), 1e-8)
1740
+
1741
+ if name == "rsi":
1742
+ return _clamp((50.0 - value) / 25.0, -1.0, 1.0)
1743
+ if name in {"stoch", "stoch_rsi"}:
1744
+ return _clamp((50.0 - value) / 30.0, -1.0, 1.0)
1745
+ if name == "cci":
1746
+ return _clamp(-value / 150.0, -1.0, 1.0)
1747
+ if name == "adx":
1748
+ plus_di = float(kw.get("plus_di", 0.0) or 0.0)
1749
+ minus_di = float(kw.get("minus_di", 0.0) or 0.0)
1750
+ strength = _clamp((value - 18.0) / 22.0, 0.0, 1.0)
1751
+ direction = math.tanh((plus_di - minus_di) / max(plus_di + minus_di, 10.0) * 3.0)
1752
+ return _clamp(direction * strength, -1.0, 1.0)
1753
+ if name in {"ao", "momentum", "bbp", "dpo"}:
1754
+ return _clamp(math.tanh(value / scale), -1.0, 1.0)
1755
+ if name == "macd":
1756
+ signal = float(kw.get("signal", 0.0) or 0.0)
1757
+ return _clamp(math.tanh((value - signal) / scale), -1.0, 1.0)
1758
+ if name == "williams":
1759
+ return _clamp(((-50.0) - value) / 30.0, -1.0, 1.0)
1760
+ if name == "ultimate":
1761
+ return _clamp((50.0 - value) / 25.0, -1.0, 1.0)
1762
+ if name == "roc":
1763
+ return _clamp(math.tanh(value / 3.0), -1.0, 1.0)
1764
+ if name == "trix":
1765
+ return _clamp(math.tanh(value / 0.35), -1.0, 1.0)
1766
+ if name == "ppo":
1767
+ return _clamp(math.tanh(value / 0.8), -1.0, 1.0)
1768
+ if name == "cmo":
1769
+ return _clamp(value / 55.0, -1.0, 1.0)
1770
+ if name == "aroon":
1771
+ return _clamp(value / 65.0, -1.0, 1.0)
1772
+ if name == "tsi":
1773
+ return _clamp(value / 25.0, -1.0, 1.0)
1774
+ return 0.0
1775
+
1776
+
1777
  def compute_indicators(data: List[Dict[str, Any]]) -> Dict[str, Any]:
1778
  """Compute a full suite of technical indicators on OHLCV data."""
1779
  if len(data) < 30:
 
1807
  bb_u, bb_m, bb_l = _bollinger(closes)
1808
  atr14 = _atr(highs, lows, closes, 14)
1809
  stoch_k, stoch_d = _stoch_rsi(closes)
1810
+ roc12 = _roc(closes, 12)
1811
+ trix18 = _trix(closes, 18)
1812
+ ppo12 = _ppo(closes, 12, 26)
1813
+ cmo14 = _cmo(closes, 14)
1814
+ dpo20 = _dpo(closes, 20)
1815
+ aroon25 = _aroon_oscillator(highs, lows, 25)
1816
+ tsi25 = _tsi(closes, 25, 13)
1817
 
1818
  # Volume SMA 20 (Vectorized v6.0)
1819
  vol_sma = _sma(vols, 20)
 
1863
  else "neutral"
1864
  ),
1865
  },
1866
+ "roc": {
1867
+ "value": _last(roc12),
1868
+ "signal": "bullish" if (_last(roc12) or 0) > 1.0 else "bearish" if (_last(roc12) or 0) < -1.0 else "neutral",
1869
+ },
1870
+ "trix": {
1871
+ "value": _last(trix18),
1872
+ "signal": "bullish" if (_last(trix18) or 0) > 0 else "bearish" if (_last(trix18) or 0) < 0 else "neutral",
1873
+ },
1874
+ "ppo": {
1875
+ "value": _last(ppo12),
1876
+ "signal": "bullish" if (_last(ppo12) or 0) > 0.35 else "bearish" if (_last(ppo12) or 0) < -0.35 else "neutral",
1877
+ },
1878
+ "cmo": {
1879
+ "value": _last(cmo14),
1880
+ "signal": "bullish" if (_last(cmo14) or 0) > 20 else "bearish" if (_last(cmo14) or 0) < -20 else "neutral",
1881
+ },
1882
+ "dpo": {
1883
+ "value": _last(dpo20),
1884
+ "signal": "bullish" if (_last(dpo20) or 0) > 0 else "bearish" if (_last(dpo20) or 0) < 0 else "neutral",
1885
+ },
1886
+ "aroon": {
1887
+ "value": _last(aroon25),
1888
+ "signal": "bullish" if (_last(aroon25) or 0) > 25 else "bearish" if (_last(aroon25) or 0) < -25 else "neutral",
1889
+ },
1890
+ "tsi": {
1891
+ "value": _last(tsi25),
1892
+ "signal": "bullish" if (_last(tsi25) or 0) > 5 else "bearish" if (_last(tsi25) or 0) < -5 else "neutral",
1893
+ },
1894
  "volume": {
1895
  "last": round(float(vols[-1]), 2),
1896
  "sma20": round(float(vol_sma[-1]), 2),
 
2046
 
2047
  bias_pct = abs((scale - 1.0) * 100.0)
2048
 
2049
+ # B-2: Advanced confidence (v6.1 Rework)
2050
+ # We use a simplified version of the new AI scoring logic here to keep blended dict consistent
2051
+ forecast_ret_pct = abs((blend_p50[-1] - last_close) / last_close * 100) if last_close else 0
2052
+ atr_pct = float(indicators["atr"].get("pct") or 1.0)
2053
+
2054
+ # Certainty (band width)
2055
+ band_pct = abs(blend_p90[-1] - blend_p10[-1]) / abs(blend_p50[-1]) if abs(blend_p50[-1]) > 1e-8 else 0.1
2056
+ certainty = math.exp(-band_pct * 3.0)
2057
+
2058
+ # Confidence: derived from agreement, magnitude (vs ATR), and certainty
2059
+ base_conf = 65.0 if agreement else 45.0
2060
+ magnitude_bonus = min(20.0, (forecast_ret_pct / max(atr_pct, 0.1)) * 5.0)
2061
+
2062
+ confidence = (base_conf + magnitude_bonus) * certainty
2063
+
2064
+ # Penalize scale bias
2065
+ bias_penalty = min(15.0, abs(scale - 1.0) * 30.0)
2066
+ confidence = max(10.0, min(95.0, confidence - bias_penalty))
 
 
2067
 
2068
  return {
2069
  "p10": blend_p10,
 
2182
  signals.append(Signal("ai_forecast", _clamp(forecast_return_pct / 3.0, -1, 1) * (confidence/100.0), 2.0, f"AI {forecast_return_pct:+.2f}%"))
2183
 
2184
  # ── 11: EMA Cross (9 vs 21) ──
2185
+ signals.append(Signal("ema_cross_9_21", 1.0 if ema9 > ema21 else -1.0, 0.6, f"EMA9 {'>' if ema9>ema21 else '<'} EMA21"))
2186
 
2187
  # ── 12: RSI Extremes ──
2188
  rsi_ext = 1.0 if rsi < 20 else -1.0 if rsi > 80 else 0.0
 
2540
  return reasons, warnings, opportunities
2541
 
2542
 
2543
+ # ── Technical Analysis Weights & Constants (Dashboard Rework v6.1) ────────────
2544
+ OSC_WEIGHTS = {
2545
+ "rsi": 2.5, # Leading indicator, battle-tested
2546
+ "macd": 2.2, # Trend + momentum hybrid
2547
+ "stoch_rsi": 1.8, # High sensitivity
2548
+ "stoch": 1.3, # Classic momentum
2549
+ "cci": 1.5, # Good for extreme detection
2550
+ "adx": 1.5, # Trend strength (direction via DI)
2551
+ "williams": 1.3, # Complement to RSI
2552
+ "ultimate": 1.2, # Multi-period, less noise
2553
+ "bbp": 1.0, # Trend-following
2554
+ "ao": 0.9, # Noisy, short-term only
2555
+ "momentum": 0.8, # Lagging, lowest weight
2556
+ "roc": 1.4, # Clean rate-of-change confirmation
2557
+ "trix": 1.2, # Smoothed trend momentum
2558
+ "ppo": 1.6, # Percentage trend acceleration
2559
+ "cmo": 1.3, # Momentum regime strength
2560
+ "dpo": 1.0, # Mean-reversion / cycle context
2561
+ "aroon": 1.5, # Trend freshness / breakout context
2562
+ "tsi": 1.4, # Smoothed momentum quality
2563
+ }
2564
+
2565
+ MA_WEIGHT_MAP = {
2566
+ "ema_200": 3.0, "sma_200": 2.8,
2567
+ "ema_100": 2.3, "sma_100": 2.1,
2568
+ "ema_50": 1.8, "sma_50": 1.6,
2569
+ "ema_30": 1.3, "sma_30": 1.2,
2570
+ "ema_20": 1.1, "sma_20": 1.0,
2571
+ "ema_10": 0.8, "sma_10": 0.7,
2572
+ "vwma_20": 1.5, "ichimoku": 1.4, "hull_9": 1.3
2573
+ }
2574
+
2575
+ def _extract_osc_key(label: str) -> str:
2576
+ l = label.lower()
2577
+ if "rsi" in l and "nhanh" not in l: return "rsi"
2578
+ if "macd" in l: return "macd"
2579
+ if "stochastic %k" in l: return "stoch"
2580
+ if "nhanh" in l or "stoch_rsi" in l: return "stoch_rsi"
2581
+ if "cci" in l: return "cci"
2582
+ if "định hướng" in l or "adx" in l: return "adx"
2583
+ if "williams" in l: return "williams"
2584
+ if "ultimate" in l: return "ultimate"
2585
+ if "bbp" in l or "sức mạnh giá" in l: return "bbp"
2586
+ if "ao" in l: return "ao"
2587
+ if "xung lượng" in l or "momentum" in l: return "momentum"
2588
+ if "roc" in l: return "roc"
2589
+ if "trix" in l: return "trix"
2590
+ if "ppo" in l: return "ppo"
2591
+ if "cmo" in l: return "cmo"
2592
+ if "dpo" in l: return "dpo"
2593
+ if "aroon" in l: return "aroon"
2594
+ if "tsi" in l: return "tsi"
2595
+ return "unknown"
2596
+
2597
+ def _get_ma_weight(label: str) -> float:
2598
+ l = label.lower()
2599
+ if "hàm mũ" in l:
2600
+ p = re.findall(r"\d+", l)
2601
+ if p: return MA_WEIGHT_MAP.get(f"ema_{p[0]}", 1.0)
2602
+ if "đơn giản" in l:
2603
+ p = re.findall(r"\d+", l)
2604
+ if p: return MA_WEIGHT_MAP.get(f"sma_{p[0]}", 1.0)
2605
+ if "ichimoku" in l: return MA_WEIGHT_MAP["ichimoku"]
2606
+ if "vwma" in l or "tỷ trọng tuyến tính" in l: return MA_WEIGHT_MAP["vwma_20"]
2607
+ if "hull" in l: return MA_WEIGHT_MAP["hull_9"]
2608
+ return 1.0
2609
+
2610
+ def _gauge_to_signal(gauge: float) -> str:
2611
+ """Unified 5-level signal converter."""
2612
+ if gauge >= 75: return "Mua mạnh"
2613
+ elif gauge >= 58: return "Mua"
2614
+ elif gauge >= 42: return "Trung lập"
2615
+ elif gauge >= 25: return "Bán"
2616
+ else: return "Bán mạnh"
2617
+
2618
+
2619
+ def _gauge_to_normalized_score(gauge: float) -> float:
2620
+ """Convert a 0..100 gauge into a -1..1 frontend-friendly scale."""
2621
+ return _clamp((float(gauge) - 50.0) / 50.0, -1.0, 1.0)
2622
+
2623
+
2624
+ def _forecast_path_metrics(p50_path: np.ndarray, last_close: float) -> Dict[str, float]:
2625
+ """Measure forecast quality from the full path, not only the final endpoint."""
2626
+ if len(p50_path) == 0 or abs(last_close) <= 1e-8:
2627
+ return {
2628
+ "weighted_return_pct": 0.0,
2629
+ "final_return_pct": 0.0,
2630
+ "path_consistency": 50.0,
2631
+ "monotonicity": 50.0,
2632
+ "max_adverse_excursion_pct": 0.0,
2633
+ "mean_step_return_pct": 0.0,
2634
+ }
2635
+
2636
+ ret_path = ((p50_path / last_close) - 1.0) * 100.0
2637
+ step_weights = np.linspace(1.0, 0.65, len(ret_path))
2638
+ weighted_ret_pct = float(np.average(ret_path, weights=step_weights))
2639
+ final_ret_pct = float(ret_path[-1])
2640
+ final_sign = 0 if abs(final_ret_pct) < 0.05 else (1 if final_ret_pct > 0 else -1)
2641
+
2642
+ if len(ret_path) > 1 and final_sign != 0:
2643
+ signed_steps = [
2644
+ 1.0 if np.sign(curr - prev) == final_sign else 0.0
2645
+ for prev, curr in zip(ret_path[:-1], ret_path[1:])
2646
+ if abs(curr - prev) >= 0.02
2647
+ ]
2648
+ path_consistency = float(sum(signed_steps) / len(signed_steps)) if signed_steps else 0.5
2649
+ else:
2650
+ path_consistency = 0.5
2651
+
2652
+ if len(ret_path) > 1:
2653
+ monotonicity = float(np.mean(np.diff(ret_path) >= 0)) if final_sign >= 0 else float(np.mean(np.diff(ret_path) <= 0))
2654
+ else:
2655
+ monotonicity = 0.5
2656
+
2657
+ if final_sign > 0:
2658
+ adverse = abs(float(np.min(ret_path)))
2659
+ elif final_sign < 0:
2660
+ adverse = abs(float(np.max(ret_path)))
2661
+ else:
2662
+ adverse = max(abs(float(np.min(ret_path))), abs(float(np.max(ret_path))))
2663
+
2664
+ mean_step_return_pct = float(np.mean(np.diff(ret_path))) if len(ret_path) > 1 else final_ret_pct
2665
+ return {
2666
+ "weighted_return_pct": round(weighted_ret_pct, 2),
2667
+ "final_return_pct": round(final_ret_pct, 2),
2668
+ "path_consistency": round(path_consistency * 100.0, 1),
2669
+ "monotonicity": round(monotonicity * 100.0, 1),
2670
+ "max_adverse_excursion_pct": round(adverse, 2),
2671
+ "mean_step_return_pct": round(mean_step_return_pct, 3),
2672
+ }
2673
+
2674
+ def _calc_osc_score(osc_data: list) -> dict:
2675
+ """MODULE 1: Weighted scoring for oscillators."""
2676
+ total_weight = 0.0
2677
+ weighted_score = 0.0
2678
+ buy = sell = neutral = 0
2679
+
2680
+ for item in osc_data:
2681
+ key = _extract_osc_key(item["name"])
2682
+ w = OSC_WEIGHTS.get(key, 1.0)
2683
+ v = float(item.get("score", 0.0) or 0.0)
2684
+ # Robust case-insensitive check
2685
+ act = str(item.get("action", "")).strip().lower()
2686
+ if act == "mua":
2687
+ buy += 1
2688
+ elif act == "bán":
2689
+ sell += 1
2690
+ else:
2691
+ neutral += 1
2692
+
2693
+ weighted_score += w * v
2694
+ total_weight += w
2695
+
2696
+ normalized = weighted_score / total_weight if total_weight else 0.0
2697
+ gauge = 50.0 + normalized * 50.0
2698
+ return {
2699
+ "gauge": round(gauge, 1),
2700
+ "normalized_score": round(normalized, 4),
2701
+ "signal": _gauge_to_signal(gauge),
2702
+ "buy": buy, "sell": sell, "neutral": neutral
2703
+ }
2704
+
2705
+ def _calc_ma_score(ma_data: list, closes: np.ndarray) -> dict:
2706
+ """MODULE 2: Period-weighted scoring for MAs + Cross Bonus."""
2707
+ total_weight = 0.0
2708
+ weighted_score = 0.0
2709
+ buy = sell = neutral = 0
2710
+
2711
+ for item in ma_data:
2712
+ w = _get_ma_weight(item["name"])
2713
+ v = 0.0
2714
+ act = str(item.get("action", "")).strip().lower()
2715
+ if act == "mua":
2716
+ v = 1.0
2717
+ buy += 1
2718
+ elif act == "bán":
2719
+ v = -1.0
2720
+ sell += 1
2721
+ else:
2722
+ neutral += 1
2723
+
2724
+ weighted_score += w * v
2725
+ total_weight += w
2726
+
2727
+ # Golden Cross / Death Cross Bonus (±0.10 normalized score)
2728
+ cross_bonus = 0.0
2729
+ if len(closes) >= 200:
2730
+ ema50 = _ema(closes, 50)[-1]
2731
+ ema200 = _ema(closes, 200)[-1]
2732
+ if not math.isnan(ema50) and not math.isnan(ema200):
2733
+ if ema50 > ema200: cross_bonus = 0.10
2734
+ else: cross_bonus = -0.10
2735
+
2736
+ normalized = (weighted_score / total_weight) + cross_bonus if total_weight else 0.0
2737
+ normalized = max(-1.0, min(1.0, normalized))
2738
+ gauge = 50.0 + normalized * 50.0
2739
+
2740
+ return {
2741
+ "gauge": round(gauge, 1),
2742
+ "normalized_score": round(normalized, 4),
2743
+ "signal": _gauge_to_signal(gauge),
2744
+ "buy": buy, "sell": sell, "neutral": neutral,
2745
+ "golden_cross": cross_bonus > 0,
2746
+ "death_cross": cross_bonus < 0
2747
+ }
2748
+
2749
+ def _calc_ai_forecast_score(
2750
+ blended: dict,
2751
+ forecast_rows: List[Dict[str, Any]],
2752
+ last_close: float,
2753
+ indicators: dict,
2754
+ horizon: int,
2755
+ interval: str,
2756
+ ) -> dict:
2757
+ """MODULE 3: AI score from path quality, trend alignment, magnitude, and certainty."""
2758
+ trend = indicators.get("trend", {})
2759
+ atr_pct = max(float(indicators.get("atr", {}).get("pct") or 0.0), 0.1)
2760
+ rsi = float(indicators.get("rsi", {}).get("value") or 50.0)
2761
+
2762
+ p50_path = np.array(blended.get("p50", []), dtype=float)
2763
+ p10_path = np.array(blended.get("p10", []), dtype=float)
2764
+ p90_path = np.array(blended.get("p90", []), dtype=float)
2765
+
2766
+ if not len(p50_path):
2767
+ p50_path = np.array([last_close], dtype=float)
2768
+ if not len(p10_path):
2769
+ p10_path = np.array([last_close], dtype=float)
2770
+ if not len(p90_path):
2771
+ p90_path = np.array([last_close], dtype=float)
2772
+
2773
+ path_len = len(p50_path)
2774
+ path_metrics = _forecast_path_metrics(p50_path, last_close)
2775
+ forecast_ret_path = ((p50_path / max(last_close, 1e-8)) - 1.0) * 100.0
2776
+ final_ret_pct = path_metrics["final_return_pct"]
2777
+ weighted_ret_pct = path_metrics["weighted_return_pct"]
2778
+ directional_edge_pct = (weighted_ret_pct * 0.60) + (final_ret_pct * 0.40)
2779
+ direction_norm = math.tanh(directional_edge_pct / max(atr_pct * 1.35, 0.35))
2780
+ final_sign = 0 if abs(final_ret_pct) < 0.05 else (1 if final_ret_pct > 0 else -1)
2781
+
2782
+ path_consistency = path_metrics["path_consistency"] / 100.0
2783
+ monotonicity = path_metrics["monotonicity"] / 100.0
2784
+ adverse_excursion_pct = path_metrics["max_adverse_excursion_pct"]
2785
+
2786
+ avg_band_pct = float(np.mean((p90_path - p10_path) / np.maximum(np.abs(p50_path), 1e-8)) * 100.0)
2787
+ band_certainty = math.exp(-avg_band_pct / 4.0)
2788
+ ensemble_conf = max(0.0, min(1.0, float(blended.get("confidence", 50.0)) / 100.0))
2789
+ scale_penalty = min(0.18, abs(float(blended.get("scale", 1.0)) - 1.0) * 0.35)
2790
+ stability_penalty = min(0.20, adverse_excursion_pct / max(atr_pct * 5.0, 1.0) * 0.12)
2791
+ certainty_score = (
2792
+ ensemble_conf * 0.45 +
2793
+ band_certainty * 0.35 +
2794
+ path_consistency * 0.12 +
2795
+ monotonicity * 0.08
2796
+ ) - scale_penalty - stability_penalty
2797
+ certainty_score = max(0.08, min(0.98, certainty_score))
2798
+
2799
+ move_in_atr = abs(final_ret_pct) / atr_pct
2800
+ magnitude_score = math.tanh(move_in_atr / 1.6)
2801
+ horizon_decay = max(0.70, 1.0 - max(horizon - 12, 0) * 0.012)
2802
+ magnitude_score *= horizon_decay
2803
+
2804
+ ema_stack = bool(trend.get("ema_bullish_stack", False))
2805
+ above_200 = bool(trend.get("above_ema200", False))
2806
+ trend_alignment = 0.0
2807
+ if final_sign > 0 and ema_stack and above_200:
2808
+ trend_alignment = 0.10
2809
+ elif final_sign < 0 and (not ema_stack) and (not above_200):
2810
+ trend_alignment = -0.10
2811
+ elif final_sign > 0 and not above_200:
2812
+ trend_alignment = -0.07
2813
+ elif final_sign < 0 and above_200:
2814
+ trend_alignment = 0.07
2815
+
2816
+ exhaustion_penalty = 0.0
2817
+ if final_sign > 0 and rsi >= 74:
2818
+ exhaustion_penalty = min(0.12, (rsi - 74.0) / 100.0)
2819
+ elif final_sign < 0 and rsi <= 26:
2820
+ exhaustion_penalty = -min(0.12, (26.0 - rsi) / 100.0)
2821
+
2822
+ path_strength = 0.55 + 0.25 * path_consistency + 0.20 * monotonicity
2823
+ effective_strength = direction_norm * path_strength * (0.45 + 0.55 * certainty_score)
2824
+ directional_push = effective_strength * (22.0 + 18.0 * magnitude_score)
2825
+ alignment_push = trend_alignment * 35.0
2826
+ exhaustion_push = -exhaustion_penalty * 35.0
2827
+ gauge = max(8.0, min(92.0, 50.0 + directional_push + alignment_push + exhaustion_push))
2828
+
2829
+ confidence_pct = 35.0 + certainty_score * 55.0 + min(move_in_atr, 1.5) * 6.0
2830
+ confidence_pct = max(20.0, min(95.0, confidence_pct))
2831
+ direction_label = "bullish" if gauge >= 58 else "bearish" if gauge <= 42 else "neutral"
2832
+
2833
+ return {
2834
+ "gauge": round(gauge, 1),
2835
+ "normalized_score": round(_gauge_to_normalized_score(gauge), 4),
2836
+ "confidence_pct": round(confidence_pct, 1),
2837
+ "forecast_return_pct": round(final_ret_pct, 2),
2838
+ "weighted_return_pct": round(weighted_ret_pct, 2),
2839
+ "direction": direction_label,
2840
+ "magnitude_vs_atr": round(move_in_atr, 2),
2841
+ "band_uncertainty_pct": round(avg_band_pct, 2),
2842
+ "certainty": round(certainty_score * 100.0, 1),
2843
+ "path_consistency": round(path_consistency * 100.0, 1),
2844
+ "monotonicity": round(monotonicity * 100.0, 1),
2845
+ "max_adverse_excursion_pct": round(adverse_excursion_pct, 2),
2846
+ "path_metrics": path_metrics,
2847
+ "signal": _gauge_to_signal(gauge)
2848
+ }
2849
+
2850
+ def _calc_summary_score(osc_score: dict, ma_score: dict, ai_score: dict) -> dict:
2851
+ """MODULE 4: Normalize + Weighted combine with compatibility fields."""
2852
+ W_OSC = 0.35
2853
+ W_MA = 0.35
2854
+ W_AI = 0.30
2855
+
2856
+ composite = (osc_score["gauge"] * W_OSC + ma_score["gauge"] * W_MA + ai_score["gauge"] * W_AI)
2857
+
2858
+ # Confidence multiplier: pull towards neutral if AI certainty is low
2859
+ certainty_factor = ai_score["certainty"] / 100.0
2860
+ pulled_to_neutral = composite + (50.0 - composite) * (1.0 - certainty_factor) * 0.20
2861
+ final_gauge = max(5.0, min(95.0, pulled_to_neutral))
2862
+
2863
+ dist = abs(final_gauge - 50.0)
2864
+ conviction = "Rất mạnh" if dist >= 25 else "Mạnh" if dist >= 15 else "Trung bình" if dist >= 8 else "Yếu"
2865
+
2866
+ # Compatibility fields for legacy frontend (Total votes)
2867
+ buy = osc_score["buy"] + ma_score["buy"]
2868
+ sell = osc_score["sell"] + ma_score["sell"]
2869
+ neutral = osc_score["neutral"] + ma_score["neutral"]
2870
+
2871
+ bias = "neutral"
2872
+ if final_gauge >= 58: bias = "bullish"
2873
+ elif final_gauge <= 42: bias = "bearish"
2874
+
2875
+ return {
2876
+ "gauge": round(final_gauge, 1),
2877
+ "signal": _gauge_to_signal(final_gauge),
2878
+ "conviction": conviction,
2879
+ "bias": bias,
2880
+ "buy": buy, "sell": sell, "neutral": neutral,
2881
+ "components": {
2882
+ "oscillators": round(osc_score["gauge"], 1),
2883
+ "moving_averages": round(ma_score["gauge"], 1),
2884
+ "ai_forecast": round(ai_score["gauge"], 1)
2885
+ }
2886
+ }
2887
+
2888
+ def _calc_technical_score_v2(osc_score: dict, ma_score: dict) -> dict:
2889
+ """Blend oscillators and moving averages into a single technical gauge."""
2890
+ base = (osc_score["gauge"] * 0.42) + (ma_score["gauge"] * 0.58)
2891
+ osc_delta = osc_score["gauge"] - 50.0
2892
+ ma_delta = ma_score["gauge"] - 50.0
2893
+ osc_dir = math.copysign(1.0, osc_delta) if abs(osc_delta) >= 1.0 else 0.0
2894
+ ma_dir = math.copysign(1.0, ma_delta) if abs(ma_delta) >= 1.0 else 0.0
2895
+
2896
+ agreement_boost = 0.0
2897
+ if osc_dir != 0.0 and ma_dir != 0.0:
2898
+ if osc_dir == ma_dir:
2899
+ agreement_boost = 4.0 * osc_dir
2900
+ else:
2901
+ agreement_boost = -0.18 * (base - 50.0)
2902
+
2903
+ structure_boost = 2.5 if ma_score.get("golden_cross") else -2.5 if ma_score.get("death_cross") else 0.0
2904
+ final_gauge = max(5.0, min(95.0, base + agreement_boost + structure_boost))
2905
+
2906
+ return {
2907
+ "gauge": round(final_gauge, 1),
2908
+ "normalized_score": round(_gauge_to_normalized_score(final_gauge), 4),
2909
+ "signal": _gauge_to_signal(final_gauge),
2910
+ "buy": osc_score["buy"] + ma_score["buy"],
2911
+ "sell": osc_score["sell"] + ma_score["sell"],
2912
+ "neutral": osc_score["neutral"] + ma_score["neutral"],
2913
+ "alignment": osc_dir == ma_dir and osc_dir != 0.0,
2914
+ "components": {
2915
+ "oscillators": round(osc_score["gauge"], 1),
2916
+ "moving_averages": round(ma_score["gauge"], 1),
2917
+ }
2918
+ }
2919
+
2920
+ def _calc_summary_score_v2(tech_score: dict, ai_score: dict) -> dict:
2921
+ """Final decision score defined as the simple mean of technical and AI gauges."""
2922
+ composite = (float(tech_score["gauge"]) + float(ai_score["gauge"])) / 2.0
2923
+ final_gauge = max(5.0, min(95.0, composite))
2924
+ dist = abs(final_gauge - 50.0)
2925
+ conviction = "Rất mạnh" if dist >= 25 else "Mạnh" if dist >= 15 else "Trung bình" if dist >= 8 else "Yếu"
2926
+
2927
+ bias = "neutral"
2928
+ if final_gauge >= 58:
2929
+ bias = "bullish"
2930
+ elif final_gauge <= 42:
2931
+ bias = "bearish"
2932
+
2933
+ return {
2934
+ "gauge": round(final_gauge, 1),
2935
+ "normalized_score": round(_gauge_to_normalized_score(final_gauge), 4),
2936
+ "signal": _gauge_to_signal(final_gauge),
2937
+ "conviction": conviction,
2938
+ "bias": bias,
2939
+ "buy": tech_score["buy"],
2940
+ "sell": tech_score["sell"],
2941
+ "neutral": tech_score["neutral"],
2942
+ "components": {
2943
+ "technical": round(tech_score["gauge"], 1),
2944
+ "oscillators": round(tech_score["components"]["oscillators"], 1),
2945
+ "moving_averages": round(tech_score["components"]["moving_averages"], 1),
2946
+ "ai_forecast": round(ai_score["gauge"], 1)
2947
+ }
2948
+ }
2949
+
2950
+
2951
+ def _build_dashboard_payload(
2952
+ last_close: float,
2953
+ forecast_rows: List[Dict[str, Any]],
2954
+ technical_score: Dict[str, Any],
2955
+ ai_score: Dict[str, Any],
2956
+ summary: Dict[str, Any],
2957
+ ) -> Dict[str, Any]:
2958
+ """Single source of truth for hero gauges consumed by the frontend."""
2959
+ forecast_end = last_close
2960
+ if forecast_rows:
2961
+ forecast_end = float(forecast_rows[-1].get("p50") or last_close)
2962
+
2963
+ return {
2964
+ "technical": {
2965
+ "gauge": technical_score["gauge"],
2966
+ "normalized_score": technical_score["normalized_score"],
2967
+ "signal": technical_score["signal"],
2968
+ "buy": technical_score["buy"],
2969
+ "sell": technical_score["sell"],
2970
+ "neutral": technical_score["neutral"],
2971
+ },
2972
+ "ai": {
2973
+ "gauge": ai_score["gauge"],
2974
+ "normalized_score": ai_score["normalized_score"],
2975
+ "signal": ai_score["signal"],
2976
+ "forecast_return_pct": ai_score["forecast_return_pct"],
2977
+ "weighted_return_pct": ai_score["weighted_return_pct"],
2978
+ "confidence_pct": ai_score["confidence_pct"],
2979
+ "certainty": ai_score["certainty"],
2980
+ "path_consistency": ai_score["path_consistency"],
2981
+ "monotonicity": ai_score.get("monotonicity", 50.0),
2982
+ "max_adverse_excursion_pct": ai_score.get("max_adverse_excursion_pct", 0.0),
2983
+ "current_price": round(float(last_close), 6),
2984
+ "forecast_price": round(float(forecast_end), 6),
2985
+ },
2986
+ "summary": {
2987
+ "gauge": summary["gauge"],
2988
+ "normalized_score": summary["normalized_score"],
2989
+ "signal": summary["signal"],
2990
+ "conviction": summary["conviction"],
2991
+ "bias": summary["bias"],
2992
+ },
2993
+ }
2994
+
2995
+
2996
+ def _rebuild_blended_from_forecast_payload(cached_forecast: Optional[Dict[str, Any]], last_close: float) -> Optional[Dict[str, Any]]:
2997
+ """Reconstruct the minimum blended payload needed by the analysis engine."""
2998
+ if not cached_forecast:
2999
+ return None
3000
+
3001
+ rows = cached_forecast.get("forecast") or []
3002
+ future_rows = [row for row in rows if not row.get("is_actual")]
3003
+ if not future_rows:
3004
+ return None
3005
+
3006
+ ensemble = cached_forecast.get("ensemble", {})
3007
+ return {
3008
+ "p10": np.array([float(row.get("p10") or last_close) for row in future_rows], dtype=float),
3009
+ "p50": np.array([float(row.get("p50") or last_close) for row in future_rows], dtype=float),
3010
+ "p90": np.array([float(row.get("p90") or last_close) for row in future_rows], dtype=float),
3011
+ "agreement": bool(ensemble.get("trend_agreement", True)),
3012
+ "scale": float(ensemble.get("alignment_scale", 1.0) or 1.0),
3013
+ "confidence": float(ensemble.get("confidence", 50.0) or 50.0),
3014
+ }
3015
+
3016
  def _build_trade_analysis(
3017
  symbol: str, interval: str, data: List[Dict[str, Any]], indicators: Dict[str, Any],
3018
  forecast_rows: List[Dict[str, Any]], confidence: float, source: str,
3019
+ blended: Optional[Dict[str, Any]] = None
3020
  ) -> Dict[str, Any]:
3021
  """
3022
+ TradingView-style technical analysis dashboard (v6.1 Rework).
3023
+ Implements weighted scoring for Oscillators, MAs, and AI Forecast.
 
3024
  """
3025
  if not data or len(data) < 30:
3026
+ return {"oscillators": {"data": []}, "moving_averages": {"data": []}, "summary": {"signal": "Trung lập", "buy": 0, "sell": 0, "neutral": 0}}
3027
 
3028
  closes = np.array([float(d["close"]) for d in data], dtype=float)
3029
  highs = np.array([float(d["high"]) for d in data], dtype=float)
 
3032
  last_close = closes[-1]
3033
 
3034
  def _lv(arr):
3035
+ if isinstance(arr, pd.Series): arr = arr.values
 
3036
  v = arr[-1] if len(arr) else float('nan')
3037
  return None if (v is None or (isinstance(v, float) and math.isnan(v))) else round(float(v), 2)
3038
 
3039
+ # ── 1. Oscillators ──
 
 
 
 
 
 
 
 
 
 
 
 
3040
  osc_data = []
3041
+ atr_scale = max(float(indicators.get("atr", {}).get("value") or last_close * 0.01), max(last_close * 0.0015, 1e-6))
 
3042
  def _add_osc(label, val, action_name, **kw):
3043
+ if isinstance(val, (np.ndarray, pd.Series, list)): v = _lv(val)
3044
+ else: v = round(float(val), 2) if val is not None else None
 
 
 
 
3045
  act = _osc_action(action_name, v if v is not None else 0, **kw)
3046
+ score = _osc_signal_score(action_name, v if v is not None else 0.0, **kw)
3047
+ osc_data.append({"name": label, "value": v, "action": act, "score": round(score, 4)})
3048
+
3049
+ _add_osc("Chỉ số Sức mạnh tương đối (14)", _rsi(closes, 14), "rsi")
3050
+ _add_osc("Stochastic %K (14, 3, 3)", _stoch_rsi(closes, 14, 14, 3, 3)[0], "stoch")
3051
+ _add_osc("Chỉ số Kênh hàng hóa (20)", _cci(highs, lows, closes, 20), "cci")
3052
+ adx_vals = _adx(highs, lows, closes, 14)
3053
+ _add_osc("Chỉ số Định hướng Trung bình (14)", adx_vals[0], "adx", plus_di=_lv(adx_vals[1]) or 0, minus_di=_lv(adx_vals[2]) or 0)
3054
+ _add_osc("Chỉ số Dao động AO", _awesome_oscillator(highs, lows), "ao", scale=atr_scale * 0.8)
3055
+ _add_osc("Xung lượng (10)", _momentum(closes, 10), "momentum", scale=atr_scale * 1.15)
3056
+ macd_vals = _macd(closes, 12, 26, 9)
3057
+ _add_osc("Cấp độ MACD (12, 26)", macd_vals[0], "macd", signal=_lv(macd_vals[1]) or 0, scale=atr_scale * 0.18)
3058
+ _add_osc("Đường RSI Nhanh (3, 3, 14, 14)", _stoch_rsi(closes, 14, 14, 3, 3)[0], "stoch_rsi")
3059
+ _add_osc("Vùng Phần trăm Williams (14)", _williams_r(highs, lows, closes, 14), "williams")
3060
+ _add_osc("Sức Mạnh Giá Lên và Giá Xuống", _bull_bear_power(highs, lows, closes, 13), "bbp", scale=atr_scale * 0.9)
3061
+ _add_osc("Dao động Ultimate (7, 14, 28)", _ultimate_oscillator(highs, lows, closes, 7, 14, 28), "ultimate")
3062
+ _add_osc("Tốc độ biến động ROC (12)", _roc(closes, 12), "roc")
3063
+ _add_osc("TRIX (18)", _trix(closes, 18), "trix")
3064
+ _add_osc("PPO (12, 26)", _ppo(closes, 12, 26), "ppo")
3065
+ _add_osc("CMO (14)", _cmo(closes, 14), "cmo")
3066
+ _add_osc("DPO (20)", _dpo(closes, 20), "dpo", scale=atr_scale * 0.75)
3067
+ _add_osc("Aroon Oscillator (25)", _aroon_oscillator(highs, lows, 25), "aroon")
3068
+ _add_osc("TSI (25, 13)", _tsi(closes, 25, 13), "tsi")
3069
+
3070
+ osc_score = _calc_osc_score(osc_data)
3071
+
3072
+ # ── 2. Moving Averages ──
3073
  ma_data = []
 
 
3074
  def _add_ma(label, val_arr):
 
3075
  v = _lv(val_arr)
3076
  act = _ma_action(last_close, v if v is not None else last_close)
3077
  ma_data.append({"name": label, "value": v, "action": act})
 
 
 
3078
 
 
3079
  for p in [10, 20, 30, 50, 100, 200]:
3080
  _add_ma(f"Trung bình Trượt Hàm mũ ({p})", _ema(closes, p))
3081
  _add_ma(f"Đường Trung bình trượt Đơn giản ({p})", _sma(closes, p))
3082
+ _add_ma("Đường cơ sở Ichimoku (9, 26, 52, 26)", _ichimoku_base(highs, lows, 26))
3083
+ _add_ma("Đường Trung bình di động Tỷ trọng tuyến tính (20)", _vwma(closes, vols, 20))
3084
+ _add_ma("Đường trung bình trượt Hull (9)", _hull_ma(closes, 9))
3085
+
3086
+ ma_score = _calc_ma_score(ma_data, closes)
3087
+
3088
+ # ── 3. AI Forecast Gauge ──
3089
+ if not blended:
3090
+ # Fallback to simple blended if no forecast available
3091
+ blended = {
3092
+ "p50": [last_close * (1 + (confidence-50)/1000)],
3093
+ "p10": [last_close * 0.98], "p90": [last_close * 1.02],
3094
+ "agreement": True, "scale": 1.0
3095
+ }
3096
+
3097
+ ai_score = _calc_ai_forecast_score(
3098
+ blended,
3099
+ forecast_rows,
3100
+ last_close,
3101
+ indicators,
3102
+ len(forecast_rows) or 10,
3103
+ interval,
3104
+ )
3105
 
3106
+ # ── 4. Summary ──
3107
+ technical_score = _calc_technical_score_v2(osc_score, ma_score)
3108
+ summary = _calc_summary_score_v2(technical_score, ai_score)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3109
 
3110
+ # Pivot Points
3111
  last_h = float(highs[-2]) if len(highs) > 1 else float(highs[-1])
3112
  last_l = float(lows[-2]) if len(lows) > 1 else float(lows[-1])
3113
  last_c = float(closes[-2]) if len(closes) > 1 else float(closes[-1])
3114
  pivots = _calc_pivot_points(last_h, last_l, last_c)
3115
+ dashboard = _build_dashboard_payload(last_close, forecast_rows, technical_score, ai_score, summary)
3116
 
3117
  return {
3118
  "style": "tradingview",
3119
+ "dashboard": dashboard,
3120
+ "summary": summary,
3121
+ "technicals": technical_score,
 
 
3122
  "oscillators": {
3123
+ "gauge": osc_score["gauge"],
3124
+ "signal": osc_score["signal"],
3125
+ "buy": osc_score["buy"],
3126
+ "sell": osc_score["sell"],
3127
+ "neutral": osc_score["neutral"],
3128
+ "data": osc_data
3129
  },
3130
  "moving_averages": {
3131
+ "gauge": ma_score["gauge"],
3132
+ "signal": ma_score["signal"],
3133
+ "buy": ma_score["buy"],
3134
+ "sell": ma_score["sell"],
3135
+ "neutral": ma_score["neutral"],
3136
+ "golden_cross": ma_score["golden_cross"],
3137
+ "death_cross": ma_score["death_cross"],
3138
+ "data": ma_data
3139
  },
3140
+ "ai_gauge": ai_score,
3141
+ "pivot_points": pivots
3142
  }
3143
 
3144
 
3145
+
3146
  START_TIME = time.time()
3147
 
3148
  async def _background_cleanup():
 
3751
  symbol: str,
3752
  interval: str = Query("1h"),
3753
  limit: int = Query(500, ge=50, le=2000),
3754
+ refresh: bool = Query(False),
3755
  ) -> Dict[str, Any]:
3756
  symbol = _get_canonical_symbol(symbol)
3757
  if symbol not in SYMBOLS:
3758
  raise HTTPException(404, f"Unknown symbol: {symbol}")
3759
  if interval not in SUPPORTED_INTERVALS:
3760
  raise HTTPException(400, f"Unsupported interval: {interval}")
3761
+ data, source = await fetch_historical(symbol, interval, limit, refresh=refresh)
3762
  return {"symbol": symbol, "interval": interval, "source": source,
3763
+ "count": len(data), "data": data,
3764
+ "generated_at": int(time.time()),
3765
+ "cache": {"refresh_requested": refresh}}
3766
 
3767
 
3768
  # ── Technical Indicators ──────────────────────────────────────────────────────
 
3771
  symbol: str,
3772
  interval: str = Query("1h"),
3773
  limit: int = Query(300, ge=50, le=1000),
3774
+ refresh: bool = Query(False),
3775
  ) -> Dict[str, Any]:
3776
  symbol = _get_canonical_symbol(symbol)
3777
  if symbol not in SYMBOLS:
 
3779
  if interval not in SUPPORTED_INTERVALS:
3780
  raise HTTPException(400, f"Unsupported interval: {interval}")
3781
 
3782
+ data, source = await fetch_historical(symbol, interval, limit, refresh=refresh)
3783
  indicators = compute_indicators(data)
3784
  return {
3785
  "symbol": symbol,
3786
  "interval": interval,
3787
  "source": source,
3788
  "candles": len(data),
3789
+ "generated_at": int(time.time()),
3790
+ "cache": {"refresh_requested": refresh},
3791
  "indicators": indicators,
3792
  }
3793
 
 
3797
  async def get_analysis(
3798
  symbol: str,
3799
  interval: str = Query("1h"),
3800
+ refresh: bool = Query(False),
3801
+ include_snapshot: bool = Query(False),
3802
  ) -> Dict[str, Any]:
3803
  """
3804
  A-5: Direct access to the comprehensive Analysis Engine.
 
3809
  raise HTTPException(404, f"Unknown symbol: {symbol}")
3810
 
3811
  # Fetch main context
3812
+ data, source = await fetch_historical(symbol, interval, 500, refresh=refresh)
3813
  if len(data) < 50:
3814
  raise HTTPException(422, "Insufficient data for full analysis")
3815
 
 
3818
  htf_bias = "neutral"
3819
  if htf_interval:
3820
  try:
3821
+ htf_data, _ = await fetch_historical(symbol, htf_interval, 200, refresh=refresh)
3822
  htf_inds = compute_indicators(htf_data)
3823
  htf_bias = "bullish" if htf_inds["trend"].get("above_ema200") else "bearish"
3824
  except Exception:
 
3827
  # Compute Indicators
3828
  indicators = compute_indicators(data)
3829
 
3830
+ # Reuse cached forecast when available so /analysis and /forecast stay consistent.
3831
  forecast_ret = 0.0
3832
  confidence = 50.0
3833
+ forecast_rows: List[Dict[str, Any]] = []
3834
+ blended: Optional[Dict[str, Any]] = None
3835
  try:
 
3836
  f_prefix = _cache_prefix(symbol, interval)
3837
  f_cache = forecast_cache.get(f"forecast_{f_prefix}10")
3838
  if f_cache:
3839
+ forecast_rows = f_cache.get("forecast") or []
3840
+ if forecast_rows:
3841
+ forecast_ret = _pct(float(forecast_rows[-1]["p50"]), float(f_cache.get("last_close") or data[-1]["close"]))
3842
+ confidence = float(f_cache.get("ensemble", {}).get("confidence") or 50.0)
3843
+ blended = _rebuild_blended_from_forecast_payload(f_cache, float(data[-1]["close"]))
3844
  except Exception:
3845
  pass
3846
 
 
3850
  interval=interval,
3851
  data=data,
3852
  indicators=indicators,
3853
+ forecast_rows=forecast_rows,
3854
  confidence=confidence,
3855
  source=source,
3856
+ blended=blended,
3857
  )
3858
 
3859
  # Inject MTF into analysis
 
3870
  "timestamp": int(time.time()),
3871
  "analysis": analysis,
3872
  "verdict": await get_gemini_verdict(symbol, analysis, forecast_ret),
3873
+ "cache": {"refresh_requested": refresh},
3874
+ "indicators_snapshot": indicators if include_snapshot else None
3875
  }
3876
 
3877
 
 
3936
  symbol: str,
3937
  interval: str = Query("1h"),
3938
  horizon: int = Query(10, ge=5, le=300),
3939
+ refresh: bool = Query(False)
3940
  ) -> Dict[str, Any]:
3941
  symbol = _get_canonical_symbol(symbol)
3942
  if symbol not in SYMBOLS:
 
3946
 
3947
  prefix = _cache_prefix(symbol, interval)
3948
  cache_key = f"forecast_{prefix}{horizon}"
3949
+ cache_origin = "live"
3950
 
3951
+ # L1/L2 Caches (Bypass if refresh=True)
3952
+ if not refresh:
3953
+ cached = forecast_cache.get(cache_key)
3954
+ if cached is not None:
3955
+ cached["generated_at"] = int(time.time())
3956
+ cached["cache"] = {"origin": "memory", "refresh_requested": False}
3957
+ return cached
3958
+ p_cached = persistent_cache.get(cache_key)
3959
+ if p_cached is not None:
3960
+ p_cached["from_persistent_cache"] = True
3961
+ forecast_cache.set(cache_key, p_cached, ttl_seconds=forecast_ttl(interval))
3962
+ p_cached["generated_at"] = int(time.time())
3963
+ p_cached["cache"] = {"origin": "persistent", "refresh_requested": False}
3964
+ return p_cached
3965
+ else:
3966
+ logger.info("[forecast] Refresh requested for %s %s. Bypassing caches.", symbol, interval)
3967
+ cache_origin = "live_refresh"
3968
 
3969
+ data_list, source = await fetch_historical(symbol, interval, 1500, refresh=refresh)
3970
  if not KRONOS_AVAILABLE:
3971
  # Return graceful empty forecast so UI doesn't break
3972
  return {
 
4044
  forecast_rows=forecast_rows,
4045
  confidence=float(blended["confidence"]),
4046
  source=source,
4047
+ blended=blended
4048
  )
4049
 
4050
  response = {
 
4071
  },
4072
  "indicators_snapshot": indicators,
4073
  "analysis": analysis,
4074
+ "generated_at": int(time.time()),
4075
+ "cache": {
4076
+ "origin": cache_origin,
4077
+ "refresh_requested": refresh,
4078
+ },
4079
  }
4080
 
4081
  # L1: RAM
 
4229
  # We'll rely on the prompt to enforce this, but can sanitize here
4230
  return text
4231
  return "Không có phản hồi từ AI"
4232
+ except Exception as e:
4233
+ logger.error("[Gemini] Exception: %s", e, exc_info=True)
4234
  return "Lỗi phân tích AI"
4235
 
4236
 
frontend/Light_BG.png CHANGED

Git LFS Details

  • SHA256: 9c26c2657f3c511f1bc5229c9c30666a51755d358a68459061a47cfa211d35ba
  • Pointer size: 132 Bytes
  • Size of remote file: 2.18 MB

Git LFS Details

  • SHA256: 14f84503397b2e2bc4637eb819728f71d5c90d97df71036d8f89f9265f9ed332
  • Pointer size: 131 Bytes
  • Size of remote file: 308 kB
frontend/index.html CHANGED
@@ -1,4 +1,4 @@
1
- <!doctype html>
2
  <html lang="vi">
3
 
4
  <head>
@@ -82,6 +82,7 @@
82
  --sidebar-w: 280px;
83
  --radius: 8px;
84
  --radius-lg: 18px;
 
85
  --ctrl-h: 38px;
86
  --ok-glow: rgba(16, 185, 129, 0.3);
87
  --err-glow: rgba(244, 63, 94, 0.3);
@@ -94,6 +95,13 @@
94
  --neon-pink: #ec4899;
95
  --neon-green: #10b981;
96
  --neon-blue: #3b82f6;
 
 
 
 
 
 
 
97
  }
98
 
99
  body.dark-theme {
@@ -132,6 +140,11 @@
132
  --glow-accent: 0 0 20px rgba(34, 211, 238, 0.4);
133
  --glass-cyan: rgba(6, 18, 42, 0.85);
134
  --shadow-lg: 0 30px 60px rgba(0, 0, 0, 0.8), 0 0 0 1px var(--bdr-accent);
 
 
 
 
 
135
  }
136
 
137
  /* ═══════════════════════════════════════════════
@@ -167,16 +180,17 @@
167
  position: fixed;
168
  inset: 0;
169
  z-index: -1;
170
- background: url("Light_BG.png");
171
  background-size: cover;
172
  background-position: center;
173
  background-repeat: no-repeat;
174
- opacity: 0.01; /* 1% opacity for the image as requested */
 
175
  transition: background 0.3s ease, opacity 0.3s ease;
176
  }
177
 
178
  body.dark-theme::before {
179
- background: url("Dark_BG.png");
180
  background-size: cover;
181
  background-position: center;
182
  background-repeat: no-repeat;
@@ -277,7 +291,7 @@
277
  .logo-mark svg {
278
  width: 100%;
279
  height: 100%;
280
- filter: drop-shadow(0 0 6px var(--accent-glow));
281
  }
282
 
283
  .logo-text {
@@ -291,7 +305,7 @@
291
  font-size: 1.45rem;
292
  font-weight: 700;
293
  letter-spacing: 0.12em;
294
- color: #ffffff;
295
  line-height: 1;
296
  background: linear-gradient(90deg, #ffffff 0%, #a8f4ff 60%, #ffffff 100%);
297
  -webkit-background-clip: text;
@@ -310,7 +324,7 @@
310
  font-family: var(--ff-ui);
311
  font-weight: 400;
312
  letter-spacing: 0.05em;
313
- color: #ffffff;
314
  line-height: 1.2;
315
  }
316
 
@@ -333,10 +347,10 @@
333
  /* Keep tight, but handle overflow via media queries */
334
  }
335
 
336
- /* Search Omnibox */
337
  .omnibox {
338
  position: relative;
339
- width: 320px;
340
  }
341
 
342
  .omnibox input {
@@ -466,7 +480,7 @@
466
  font-size: 0.78rem;
467
  font-weight: 500;
468
  letter-spacing: 0.18em;
469
- color: #ffffff;
470
  text-transform: uppercase;
471
  padding-left: 2px;
472
  line-height: 1;
@@ -729,7 +743,10 @@
729
  flex: 1;
730
  display: flex;
731
  flex-direction: column;
732
- overflow: hidden;
 
 
 
733
  }
734
 
735
  /* ── TOP: 3 Big Gauges Row ── */
@@ -890,7 +907,8 @@
890
  }
891
 
892
  .gauge-hero-signal.neutral {
893
- color: #cbd5e1;
 
894
  }
895
 
896
  .gauge-hero-signal.signal-total {
@@ -901,7 +919,7 @@
901
  display: flex;
902
  gap: 20px;
903
  font-size: 0.8rem;
904
- color: rgba(255, 255, 255, 0.4);
905
  letter-spacing: 0.02em;
906
  }
907
 
@@ -914,18 +932,19 @@
914
  font-family: var(--ff-mono);
915
  font-weight: 900;
916
  font-size: 1.1rem;
917
- color: #ffffff;
918
  display: block;
919
  margin-top: 2px;
920
  }
921
 
922
  /* ── BOTTOM: Data Tables Grid ── */
923
  .dash-tables-row {
924
- flex: 1;
925
  display: grid;
926
  overflow: hidden;
927
  grid-template-columns: 1fr 1fr 1fr;
928
  gap: 0;
 
929
  }
930
 
931
  .dash-col {
@@ -933,6 +952,8 @@
933
  flex-direction: column;
934
  border-right: 1px solid var(--bdr-dim);
935
  overflow: hidden;
 
 
936
  }
937
 
938
  .dash-col:last-child {
@@ -1055,7 +1076,7 @@
1055
  }
1056
 
1057
  .summary-disclaimer strong {
1058
- color: rgba(255, 255, 255, 0.4);
1059
  }
1060
 
1061
  /* ── Loading state ── */
@@ -1066,7 +1087,7 @@
1066
  justify-content: center;
1067
  height: 100%;
1068
  gap: 16px;
1069
- color: rgba(255, 255, 255, 0.4);
1070
  }
1071
 
1072
  .dash-loading .loader-ring {
@@ -1129,7 +1150,7 @@
1129
  pointer-events: none;
1130
  z-index: 1;
1131
  opacity: 0.12;
1132
- background-image: url('Light_BG.png');
1133
  background-size: contain;
1134
  background-repeat: no-repeat;
1135
  background-position: left bottom;
@@ -1139,7 +1160,7 @@
1139
  }
1140
 
1141
  body.dark-theme .chart-bg-overlay {
1142
- background-image: url('Dark_BG.png');
1143
  opacity: 0.18;
1144
  }
1145
 
@@ -1509,8 +1530,15 @@
1509
  }
1510
 
1511
  @keyframes gauges-fade-in {
1512
- from { opacity: 0; transform: translateX(-10px); }
1513
- to { opacity: 1; transform: translateX(0); }
 
 
 
 
 
 
 
1514
  }
1515
 
1516
  .compact-gauge-card {
@@ -1756,10 +1784,870 @@
1756
  text-transform: uppercase;
1757
  letter-spacing: 0.05em;
1758
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1759
  </style>
1760
  </head>
1761
 
1762
  <body>
 
 
 
 
 
1763
  <div id="app">
1764
 
1765
  <!-- ── HEADER ───────────────────────────────── -->
@@ -1768,20 +2656,82 @@
1768
  <!-- Logo -->
1769
  <div class="logo">
1770
  <div class="logo-mark">
1771
- <!-- Stylised K hexagon mark -->
1772
- <svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
1773
- <polygon points="16,2 28,9 28,23 16,30 4,23 4,9" stroke="rgba(34,211,238,0.55)" stroke-width="1"
1774
- fill="rgba(34,211,238,0.06)" />
1775
- <polygon points="16,6 24,11 24,21 16,26 8,21 8,11" stroke="rgba(34,211,238,0.22)" stroke-width="0.5"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1776
  fill="none" />
1777
- <!-- AI shape -->
1778
- <path d="M9,22 L13,10 L17,22 M10.5,18 L15.5,18" stroke="#22d3ee" stroke-width="1.8" stroke-linecap="round"
1779
- stroke-linejoin="round" />
1780
- <line x1="22" y1="10" x2="22" y2="22" stroke="#22d3ee" stroke-width="1.8" stroke-linecap="round" />
1781
- <!-- corner accents -->
1782
- <circle cx="16" cy="2" r="1" fill="rgba(34,211,238,0.5)" />
1783
- <circle cx="28" cy="9" r="1" fill="rgba(34,211,238,0.3)" />
1784
- <circle cx="4" cy="23" r="1" fill="rgba(34,211,238,0.3)" />
 
 
1785
  </svg>
1786
  </div>
1787
  <!-- Market Status -->
@@ -1816,6 +2766,7 @@
1816
  <div class="ctrl-unit">
1817
  <span class="ctrl-label">Khung thời gian</span>
1818
  <select class="k-select" id="timeframeSelect">
 
1819
  <option>1m</option>
1820
  <option>5m</option>
1821
  <option>15m</option>
@@ -1828,12 +2779,13 @@
1828
 
1829
  <div class="ctrl-unit">
1830
  <span class="ctrl-label">Dự báo (nến)</span>
1831
- <input class="k-input" id="horizonInput" type="number" min="5" max="300" value="10" />
1832
  </div>
1833
 
1834
  <div class="ctrl-unit">
1835
  <span class="ctrl-label">Chỉ báo</span>
1836
  <select class="k-select" id="indicatorSelect" style="width: 140px;">
 
1837
  <option value="none">Không có</option>
1838
  <option value="bb">Bollinger Bands</option>
1839
  <option value="rsi">RSI (14)</option>
@@ -1963,6 +2915,8 @@
1963
  const analysisPanel = document.getElementById('analysisPanel');
1964
  const marketStatusBar = document.getElementById('marketStatusBar');
1965
  const indicatorSelect = document.getElementById('indicatorSelect');
 
 
1966
 
1967
  /* ── State ─────────────────────────────────── */
1968
  let currentSymbol = 'XAUUSD';
@@ -1977,6 +2931,49 @@
1977
 
1978
  /* ── Indicator Calculation Helpers ────────── */
1979
  /* ── WebSocket Management ────────────────────── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1980
  function connectWS(symbol) {
1981
  if (ws) {
1982
  ws.close();
@@ -2159,26 +3156,62 @@
2159
  });
2160
 
2161
  const p50Series = chart.addLineSeries({
2162
- color: '#22d3ee',
2163
  lineWidth: 2,
2164
  title: 'Dự báo AI',
2165
  priceLineVisible: false,
2166
- lastValueVisible: true,
2167
  visible: false,
2168
  });
2169
 
2170
  const p10Series = chart.addLineSeries({
2171
- color: 'rgba(34, 211, 238, 0.22)',
2172
- lineWidth: 1,
2173
  lineStyle: LightweightCharts.LineStyle.Dashed,
2174
  priceLineVisible: false,
2175
  lastValueVisible: false,
2176
  visible: false,
2177
  });
2178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2179
  const p90Series = chart.addLineSeries({
2180
- color: 'rgba(34, 211, 238, 0.22)',
2181
- lineWidth: 1,
2182
  lineStyle: LightweightCharts.LineStyle.Dashed,
2183
  priceLineVisible: false,
2184
  lastValueVisible: false,
@@ -2258,16 +3291,20 @@
2258
  const angle = score * 135;
2259
  const cx = w / 2, cy = h * 0.65, r = (w / 2) * 0.62;
2260
  const strokeW = w > 150 ? 16 : 8;
2261
-
2262
  function arc(s, e, col) {
2263
  const sa = (s - 90) * Math.PI / 180, ea = (e - 90) * Math.PI / 180;
2264
  const x1 = cx + r * Math.cos(sa), y1 = cy + r * Math.sin(sa), x2 = cx + r * Math.cos(ea), y2 = cy + r * Math.sin(ea);
2265
  return `<path d="M${x1},${y1} A${r},${r} 0 ${(e - s) > 180 ? 1 : 0} 1 ${x2},${y2}" fill="none" stroke="${col}" stroke-width="${strokeW}" stroke-linecap="round" opacity="0.8"/>`;
2266
  }
2267
-
2268
  const na = (angle - 90) * Math.PI / 180, nl = r + 2;
2269
  const nx = cx + nl * Math.cos(na), ny = cy + nl * Math.sin(na);
2270
- const needleColor = score > 0.3 ? '#4ade80' : score > 0.1 ? '#bbf7d0' : score < -0.3 ? '#f87171' : score < -0.1 ? '#fecaca' : '#f1f5f9';
 
 
 
 
2271
 
2272
  let valueHtml = '';
2273
  if (showValue) {
@@ -2297,7 +3334,14 @@
2297
  `;
2298
  }
2299
 
 
 
 
 
 
 
2300
  function getSignalClass(signal) {
 
2301
  if (signal.includes('Mua mạnh')) return 'strong-buy';
2302
  if (signal.includes('Mua')) return 'buy';
2303
  if (signal.includes('Bán mạnh')) return 'strong-sell';
@@ -2313,47 +3357,26 @@
2313
  }
2314
 
2315
  const a = payload.analysis;
2316
- const summary = a.summary || { sell: 0, neutral: 0, buy: 0, signal: '--' };
2317
-
2318
- // Re-calculate scores (logic mirrored from renderAnalysisPanel)
2319
- const lastClose = payload.last_close || 0;
2320
- const forecastRows = payload.forecast || [];
2321
- let forecastEnd = lastClose;
2322
- if (forecastRows.length > 1) forecastEnd = forecastRows[forecastRows.length - 1]?.p50 ?? lastClose;
2323
- const forecastPctChange = lastClose > 0 ? ((forecastEnd - lastClose) / lastClose) * 100 : 0;
2324
-
2325
- const techTotal = (summary.buy + summary.sell + summary.neutral) || 1;
2326
- const techScore = (summary.buy - summary.sell) / techTotal;
2327
- const aiScore = Math.max(-1, Math.min(1, forecastPctChange / 2));
2328
- const combinedScore = (techScore + aiScore) / 2;
2329
-
2330
- let aiSignal = 'Trung lập';
2331
- if (forecastPctChange > 2) aiSignal = 'Mua mạnh';
2332
- else if (forecastPctChange > 0.5) aiSignal = 'Mua';
2333
- else if (forecastPctChange < -2) aiSignal = 'Bán mạnh';
2334
- else if (forecastPctChange < -0.5) aiSignal = 'Bán';
2335
-
2336
- let totalSignal = 'Trung lập';
2337
- if (combinedScore > 0.4) totalSignal = 'Mua mạnh';
2338
- else if (combinedScore > 0.1) totalSignal = 'Mua';
2339
- else if (combinedScore < -0.4) totalSignal = 'Bán mạnh';
2340
- else if (combinedScore < -0.1) totalSignal = 'Bán';
2341
 
2342
  container.innerHTML = `
2343
- <div class="compact-gauge-card">
2344
  <div class="compact-gauge-title">Kỹ thuật</div>
2345
- <div class="compact-gauge-svg-wrap">${buildGaugeSvg(techScore, 80, 50, false)}</div>
2346
- <div class="compact-gauge-signal ${getSignalClass(summary.signal)}">${summary.signal}</div>
2347
  </div>
2348
- <div class="compact-gauge-card">
2349
  <div class="compact-gauge-title">Dự báo AI</div>
2350
- <div class="compact-gauge-svg-wrap">${buildGaugeSvg(aiScore, 80, 50, false)}</div>
2351
- <div class="compact-gauge-signal ${getSignalClass(aiSignal)}">${aiSignal}</div>
2352
  </div>
2353
- <div class="compact-gauge-card hero">
2354
  <div class="compact-gauge-title">Tổng kết</div>
2355
- <div class="compact-gauge-svg-wrap">${buildGaugeSvg(combinedScore, 80, 50, false)}</div>
2356
- <div class="compact-gauge-signal ${getSignalClass(totalSignal)}">${totalSignal}</div>
2357
  </div>
2358
  `;
2359
  }
@@ -2391,54 +3414,16 @@
2391
  const osc = a.oscillators || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
2392
  const ma = a.moving_averages || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
2393
  const summary = a.summary || { sell: 0, neutral: 0, buy: 0, signal: '--' };
 
 
 
2394
  const pivots = (a.pivot_points || {}).data || [];
2395
 
2396
- // ── AI Forecast calculation ──
2397
- const confidence = payload.ensemble?.confidence ?? 0;
2398
  const forecastRows = payload.forecast || [];
2399
  const lastClose = payload.last_close || 0;
2400
- let forecastEnd = lastClose;
2401
- if (forecastRows.length > 1) {
2402
- forecastEnd = forecastRows[forecastRows.length - 1]?.p50 ?? lastClose;
2403
- }
2404
- const forecastPctChange = lastClose > 0 ? ((forecastEnd - lastClose) / lastClose) * 100 : 0;
2405
-
2406
- // ── Compute AI Forecast as Buy/Sell gauge ──
2407
- // Map forecast % change to a score: >0 = buy, <0 = sell
2408
- // Strength determines strong/weak
2409
- const absChange = Math.abs(forecastPctChange);
2410
- let aiBuy = 0, aiSell = 0, aiNeutral = 0;
2411
- if (forecastPctChange > 0.5) {
2412
- aiBuy = absChange > 2 ? 2 : 1;
2413
- aiNeutral = absChange > 2 ? 0 : 1;
2414
- } else if (forecastPctChange < -0.5) {
2415
- aiSell = absChange > 2 ? 2 : 1;
2416
- aiNeutral = absChange > 2 ? 0 : 1;
2417
- } else {
2418
- aiNeutral = 1;
2419
- }
2420
- const aiTotal = aiBuy + aiSell + aiNeutral;
2421
- let aiSignal = 'Trung lập';
2422
- if (forecastPctChange > 2) aiSignal = 'Mua mạnh';
2423
- else if (forecastPctChange > 0.5) aiSignal = 'Mua';
2424
- else if (forecastPctChange < -2) aiSignal = 'Bán mạnh';
2425
- else if (forecastPctChange < -0.5) aiSignal = 'Bán';
2426
-
2427
- // ── TỔNG KẾT = (Phân tích kỹ thuật + Dự báo AI) / 2 ──
2428
- const techTotal = (summary.buy + summary.sell + summary.neutral) || 1;
2429
- const techScore = (summary.buy - summary.sell) / techTotal; // -1 to +1
2430
-
2431
- // Normalized AI Score: Map forecast change to -1 to +1 range
2432
- // We'll consider 2% change as the "strong" threshold
2433
- const aiScore = Math.max(-1, Math.min(1, forecastPctChange / 2));
2434
-
2435
- const combinedScore = (techScore + aiScore) / 2; // -1 to +1
2436
-
2437
- let totalSignal = 'Trung lập';
2438
- if (combinedScore > 0.4) totalSignal = 'Mua mạnh';
2439
- else if (combinedScore > 0.1) totalSignal = 'Mua';
2440
- else if (combinedScore < -0.4) totalSignal = 'Bán mạnh';
2441
- else if (combinedScore < -0.1) totalSignal = 'Bán';
2442
 
2443
  // ── Big SVG Gauge builder (Refactored to buildGaugeSvg) ──
2444
 
@@ -2483,13 +3468,13 @@
2483
  <div class="gauge-hero-card">
2484
  <div class="gauge-hero-title">PHÂN TÍCH KỸ THUẬT</div>
2485
  <div class="gauge-hero-svg-wrap">
2486
- ${buildGaugeSvg(techScore)}
2487
  </div>
2488
- <div class="gauge-hero-signal ${signalClass(summary.signal)}">${summary.signal}</div>
2489
  <div class="gauge-hero-counts">
2490
- <span><span class="ghc-label">Bán</span><span class="ghc-value">${summary.sell}</span></span>
2491
- <span><span class="ghc-label">Trung lập</span><span class="ghc-value">${summary.neutral}</span></span>
2492
- <span><span class="ghc-label">Mua</span><span class="ghc-value">${summary.buy}</span></span>
2493
  </div>
2494
  </div>
2495
 
@@ -2497,12 +3482,12 @@
2497
  <div class="gauge-hero-card">
2498
  <div class="gauge-hero-title">DỰ BÁO AI</div>
2499
  <div class="gauge-hero-svg-wrap">
2500
- ${buildGaugeSvg(aiScore)}
2501
  </div>
2502
  <div class="gauge-hero-ai-details">
2503
  <div class="gh-ai-row">
2504
  <span class="gh-ai-label">Hiện tại:</span>
2505
- <span class="gh-ai-val">${formatPrice(lastClose)}</span>
2506
  </div>
2507
  <div class="gh-ai-row">
2508
  <span class="gh-ai-label">Dự kiến:</span>
@@ -2512,17 +3497,25 @@
2512
  <span class="gh-ai-label">Biến động:</span>
2513
  <span class="gh-ai-val ${forecastPctChange >= 0 ? 'up' : 'down'}">${forecastPctChange >= 0 ? '↑' : '↓'} ${Math.abs(forecastPctChange).toFixed(2)}%</span>
2514
  </div>
 
 
 
 
 
 
 
 
2515
  </div>
2516
- <div class="gauge-hero-signal ${signalClass(aiSignal)}">${aiSignal}</div>
2517
  </div>
2518
 
2519
  <!-- Gauge 3: TỔNG KẾT -->
2520
  <div class="gauge-hero-card hero-total">
2521
  <div class="gauge-hero-title title-total">⚡ TỔNG KẾT</div>
2522
  <div class="gauge-hero-svg-wrap">
2523
- ${buildGaugeSvg(combinedScore)}
2524
  </div>
2525
- <div class="gauge-hero-signal signal-total ${signalClass(totalSignal)}">${totalSignal}</div>
2526
  </div>
2527
 
2528
  </div>
@@ -2531,15 +3524,15 @@
2531
  <div class="dash-tables-row">
2532
 
2533
  <!-- Oscillators -->
2534
- <div class="dash-col">
2535
- <div class="dc-header">Chỉ số Dao động</div>
2536
  <div class="dash-table-wrap">
2537
  <table class="dt"><tbody>${oscRows}</tbody></table>
2538
  </div>
2539
  </div>
2540
 
2541
  <!-- Moving Averages -->
2542
- <div class="dash-col">
2543
  <div class="dc-header">Trung bình trượt</div>
2544
  <div class="dash-table-wrap">
2545
  <table class="dt"><tbody>${maRows}</tbody></table>
@@ -2547,7 +3540,7 @@
2547
  </div>
2548
 
2549
  <!-- Pivot Points -->
2550
- <div class="dash-col col-pivots">
2551
  <div class="dc-header">Điểm xoay</div>
2552
  <div class="dash-table-wrap">
2553
  <table class="pivot-table">
@@ -2569,6 +3562,22 @@
2569
  // Close logic
2570
  const closeBtn = document.getElementById('dashCloseBtn');
2571
  if (closeBtn) closeBtn.onclick = () => analysisPanel.classList.remove('active');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2572
  }
2573
 
2574
 
@@ -2579,6 +3588,7 @@
2579
  p50Series.setData([]);
2580
  p10Series.setData([]);
2581
  p90Series.setData([]);
 
2582
 
2583
  p50Series.applyOptions({ visible: false });
2584
  p10Series.applyOptions({ visible: false });
@@ -2738,6 +3748,13 @@
2738
  let lastForecastVal = 0;
2739
 
2740
  if (fData.error || !fData.forecast || fData.forecast.length === 0) {
 
 
 
 
 
 
 
2741
  updateStatus('AI: ' + (fData.error || 'Thiếu dữ liệu dự báo'), 'warning');
2742
  } else {
2743
  // Safeguard: Ensure we have candle data before aligning
@@ -2746,27 +3763,25 @@
2746
  return;
2747
  }
2748
 
2749
- const forecastPoints = fData.forecast; // Backend already has anchor point is_actual
2750
-
2751
- // Filter correctly: use is_actual as connection point
2752
- const p50 = forecastPoints.map(d => ({ time: d.time, value: d.p50 }));
2753
- const p10 = forecastPoints.map(d => ({ time: d.time, value: d.p10 }));
2754
- const p90 = forecastPoints.map(d => ({ time: d.time, value: d.p90 }));
2755
-
2756
- p50Series.setData(p50);
2757
- p10Series.setData(p10);
2758
- p90Series.setData(p90);
2759
-
2760
- const anchorVal = forecastPoints[0]?.p50 ?? lastCandleData.close;
 
 
 
 
2761
  lastForecastVal = forecastPoints[forecastPoints.length - 1]?.p50 ?? anchorVal;
2762
  isBull = lastForecastVal >= anchorVal;
2763
 
2764
- const aiMain = isBull ? '#1dba8a' : '#e05560';
2765
- const aiBand = isBull ? 'rgba(29,186,138,0.1)' : 'rgba(224,85,96,0.1)';
2766
-
2767
- p50Series.applyOptions({ color: aiMain, visible: true });
2768
- p10Series.applyOptions({ color: aiBand, visible: true });
2769
- p90Series.applyOptions({ color: aiBand, visible: true });
2770
  }
2771
 
2772
  const currentPrice = lastCandleData?.close || 0;
@@ -3008,4 +4023,4 @@
3008
  </script>
3009
  </body>
3010
 
3011
- </html>
 
1
+ <!doctype html>
2
  <html lang="vi">
3
 
4
  <head>
 
82
  --sidebar-w: 280px;
83
  --radius: 8px;
84
  --radius-lg: 18px;
85
+ --radius-xl: 24px;
86
  --ctrl-h: 38px;
87
  --ok-glow: rgba(16, 185, 129, 0.3);
88
  --err-glow: rgba(244, 63, 94, 0.3);
 
95
  --neon-pink: #ec4899;
96
  --neon-green: #10b981;
97
  --neon-blue: #3b82f6;
98
+
99
+ /* Logo Colors */
100
+ --logo-primary: #0ea5e9;
101
+ --logo-secondary: #8b5cf6;
102
+ --logo-glow: rgba(14, 165, 233, 0.3);
103
+ --light-bg-image: url("Light_BG.png?v=3");
104
+ --dark-bg-image: url("Dark_BG.png?v=3");
105
  }
106
 
107
  body.dark-theme {
 
140
  --glow-accent: 0 0 20px rgba(34, 211, 238, 0.4);
141
  --glass-cyan: rgba(6, 18, 42, 0.85);
142
  --shadow-lg: 0 30px 60px rgba(0, 0, 0, 0.8), 0 0 0 1px var(--bdr-accent);
143
+
144
+ /* Logo Colors */
145
+ --logo-primary: #22d3ee;
146
+ --logo-secondary: #ec4899;
147
+ --logo-glow: rgba(34, 211, 238, 0.6);
148
  }
149
 
150
  /* ═══════════════════════════════════════════════
 
180
  position: fixed;
181
  inset: 0;
182
  z-index: -1;
183
+ background: var(--light-bg-image);
184
  background-size: cover;
185
  background-position: center;
186
  background-repeat: no-repeat;
187
+ opacity: 0.01;
188
+ /* 1% opacity for the image as requested */
189
  transition: background 0.3s ease, opacity 0.3s ease;
190
  }
191
 
192
  body.dark-theme::before {
193
+ background: var(--dark-bg-image);
194
  background-size: cover;
195
  background-position: center;
196
  background-repeat: no-repeat;
 
291
  .logo-mark svg {
292
  width: 100%;
293
  height: 100%;
294
+ filter: drop-shadow(0 0 8px var(--accent-glow));
295
  }
296
 
297
  .logo-text {
 
305
  font-size: 1.45rem;
306
  font-weight: 700;
307
  letter-spacing: 0.12em;
308
+ color: var(--txt-bright);
309
  line-height: 1;
310
  background: linear-gradient(90deg, #ffffff 0%, #a8f4ff 60%, #ffffff 100%);
311
  -webkit-background-clip: text;
 
324
  font-family: var(--ff-ui);
325
  font-weight: 400;
326
  letter-spacing: 0.05em;
327
+ color: var(--txt-bright);
328
  line-height: 1.2;
329
  }
330
 
 
347
  /* Keep tight, but handle overflow via media queries */
348
  }
349
 
350
+ /* Search Omnibox (Shrunk 50% horizontally from original 320px) */
351
  .omnibox {
352
  position: relative;
353
+ width: 160px;
354
  }
355
 
356
  .omnibox input {
 
480
  font-size: 0.78rem;
481
  font-weight: 500;
482
  letter-spacing: 0.18em;
483
+ color: var(--txt-muted);
484
  text-transform: uppercase;
485
  padding-left: 2px;
486
  line-height: 1;
 
743
  flex: 1;
744
  display: flex;
745
  flex-direction: column;
746
+ overflow-x: hidden;
747
+ overflow-y: auto;
748
+ min-height: 0;
749
+ overscroll-behavior: contain;
750
  }
751
 
752
  /* ── TOP: 3 Big Gauges Row ── */
 
907
  }
908
 
909
  .gauge-hero-signal.neutral {
910
+ color: #facc15;
911
+ text-shadow: 0 0 16px rgba(250, 204, 21, 0.28);
912
  }
913
 
914
  .gauge-hero-signal.signal-total {
 
919
  display: flex;
920
  gap: 20px;
921
  font-size: 0.8rem;
922
+ color: var(--txt-muted);
923
  letter-spacing: 0.02em;
924
  }
925
 
 
932
  font-family: var(--ff-mono);
933
  font-weight: 900;
934
  font-size: 1.1rem;
935
+ color: var(--txt-bright);
936
  display: block;
937
  margin-top: 2px;
938
  }
939
 
940
  /* ── BOTTOM: Data Tables Grid ── */
941
  .dash-tables-row {
942
+ flex: 0 0 auto;
943
  display: grid;
944
  overflow: hidden;
945
  grid-template-columns: 1fr 1fr 1fr;
946
  gap: 0;
947
+ align-items: start;
948
  }
949
 
950
  .dash-col {
 
952
  flex-direction: column;
953
  border-right: 1px solid var(--bdr-dim);
954
  overflow: hidden;
955
+ min-height: 0;
956
+ align-self: start;
957
  }
958
 
959
  .dash-col:last-child {
 
1076
  }
1077
 
1078
  .summary-disclaimer strong {
1079
+ color: var(--txt-muted);
1080
  }
1081
 
1082
  /* ── Loading state ── */
 
1087
  justify-content: center;
1088
  height: 100%;
1089
  gap: 16px;
1090
+ color: var(--txt-muted);
1091
  }
1092
 
1093
  .dash-loading .loader-ring {
 
1150
  pointer-events: none;
1151
  z-index: 1;
1152
  opacity: 0.12;
1153
+ background-image: var(--light-bg-image);
1154
  background-size: contain;
1155
  background-repeat: no-repeat;
1156
  background-position: left bottom;
 
1160
  }
1161
 
1162
  body.dark-theme .chart-bg-overlay {
1163
+ background-image: var(--dark-bg-image);
1164
  opacity: 0.18;
1165
  }
1166
 
 
1530
  }
1531
 
1532
  @keyframes gauges-fade-in {
1533
+ from {
1534
+ opacity: 0;
1535
+ transform: translateX(-10px);
1536
+ }
1537
+
1538
+ to {
1539
+ opacity: 1;
1540
+ transform: translateX(0);
1541
+ }
1542
  }
1543
 
1544
  .compact-gauge-card {
 
1784
  text-transform: uppercase;
1785
  letter-spacing: 0.05em;
1786
  }
1787
+
1788
+ /* Premium Liquid Glass Override */
1789
+ :root {
1790
+ --bg-base: #edf4ff;
1791
+ --bg-depth: rgba(255, 255, 255, 0.72);
1792
+ --bg-panel: rgba(246, 250, 255, 0.72);
1793
+ --bg-glass: rgba(255, 255, 255, 0.56);
1794
+ --bg-glass-light: rgba(255, 255, 255, 0.42);
1795
+ --bg-control: rgba(255, 255, 255, 0.46);
1796
+ --bg-control-hov: rgba(255, 255, 255, 0.7);
1797
+ --bg-active: rgba(39, 126, 255, 0.12);
1798
+ --bg-sidebar: rgba(240, 246, 255, 0.58);
1799
+ --bdr-dim: rgba(255, 255, 255, 0.34);
1800
+ --bdr-base: rgba(107, 143, 198, 0.2);
1801
+ --bdr-muted: rgba(99, 131, 193, 0.34);
1802
+ --bdr-accent: rgba(54, 124, 255, 0.4);
1803
+ --txt-bright: #07111f;
1804
+ --txt-primary: #132238;
1805
+ --txt-secondary: #41566f;
1806
+ --txt-muted: #6d8198;
1807
+ --accent: #1f7aff;
1808
+ --accent-lo: rgba(31, 122, 255, 0.1);
1809
+ --accent-mid: rgba(31, 122, 255, 0.24);
1810
+ --accent-glow: rgba(31, 122, 255, 0.3);
1811
+ --ok: #00b894;
1812
+ --err: #ff5470;
1813
+ --warn: #ffaf38;
1814
+ --bull: #00c58e;
1815
+ --bear: #ff5a76;
1816
+ --ff-display: 'Chakra Petch', 'Barlow', sans-serif;
1817
+ --radius: 18px;
1818
+ --radius-lg: 28px;
1819
+ --radius-xl: 36px;
1820
+ --shadow-lg: 0 30px 80px rgba(31, 55, 104, 0.12), 0 10px 26px rgba(80, 117, 180, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.72);
1821
+ --glass-cyan: rgba(255, 255, 255, 0.62);
1822
+ --neon-cyan: #3ea6ff;
1823
+ --neon-pink: #ff6bb2;
1824
+ --neon-green: #00d68f;
1825
+ --neon-blue: #5b8cff;
1826
+ --logo-primary: #2e8dff;
1827
+ --logo-secondary: #5dd4ff;
1828
+ --logo-glow: rgba(46, 141, 255, 0.36);
1829
+ }
1830
+
1831
+ body.dark-theme {
1832
+ --bg-base: #040915;
1833
+ --bg-depth: rgba(7, 13, 29, 0.72);
1834
+ --bg-panel: rgba(6, 14, 30, 0.72);
1835
+ --bg-glass: rgba(10, 18, 39, 0.5);
1836
+ --bg-glass-light: rgba(15, 24, 48, 0.38);
1837
+ --bg-control: rgba(14, 24, 49, 0.46);
1838
+ --bg-control-hov: rgba(20, 35, 67, 0.72);
1839
+ --bg-active: rgba(59, 130, 246, 0.14);
1840
+ --bg-sidebar: rgba(7, 14, 28, 0.58);
1841
+ --bdr-dim: rgba(150, 197, 255, 0.08);
1842
+ --bdr-base: rgba(120, 168, 255, 0.18);
1843
+ --bdr-muted: rgba(126, 176, 255, 0.3);
1844
+ --bdr-accent: rgba(79, 181, 255, 0.46);
1845
+ --txt-bright: #f8fbff;
1846
+ --txt-primary: #d9e8ff;
1847
+ --txt-secondary: #9eb6d4;
1848
+ --txt-muted: #6a81a6;
1849
+ --accent: #4fb5ff;
1850
+ --accent-lo: rgba(79, 181, 255, 0.12);
1851
+ --accent-mid: rgba(79, 181, 255, 0.26);
1852
+ --accent-glow: rgba(79, 181, 255, 0.44);
1853
+ --logo-primary: #59bdff;
1854
+ --logo-secondary: #71f0ff;
1855
+ --logo-glow: rgba(89, 189, 255, 0.52);
1856
+ --shadow-lg: 0 36px 100px rgba(1, 6, 18, 0.62), 0 18px 40px rgba(7, 18, 44, 0.4), inset 0 1px 0 rgba(151, 205, 255, 0.08);
1857
+ }
1858
+
1859
+ html {
1860
+ cursor: default;
1861
+ }
1862
+
1863
+ body {
1864
+ background:
1865
+ radial-gradient(circle at 12% 18%, rgba(123, 213, 255, 0.34), transparent 26%),
1866
+ radial-gradient(circle at 85% 12%, rgba(255, 150, 198, 0.22), transparent 24%),
1867
+ radial-gradient(circle at 76% 82%, rgba(68, 212, 173, 0.18), transparent 22%),
1868
+ linear-gradient(145deg, #eef5ff 0%, #edf3fb 42%, #e7eefc 100%);
1869
+ background-attachment: fixed;
1870
+ }
1871
+
1872
+ body.dark-theme {
1873
+ background:
1874
+ radial-gradient(circle at 14% 18%, rgba(54, 119, 255, 0.28), transparent 25%),
1875
+ radial-gradient(circle at 84% 18%, rgba(31, 214, 255, 0.18), transparent 24%),
1876
+ radial-gradient(circle at 72% 84%, rgba(0, 214, 143, 0.12), transparent 22%),
1877
+ linear-gradient(160deg, #030714 0%, #07101f 52%, #091428 100%);
1878
+ background-attachment: fixed;
1879
+ }
1880
+
1881
+ body::after {
1882
+ content: "";
1883
+ position: fixed;
1884
+ inset: 0;
1885
+ z-index: -1;
1886
+ pointer-events: none;
1887
+ background:
1888
+ linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
1889
+ linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
1890
+ background-size: 32px 32px;
1891
+ mask-image: radial-gradient(circle at center, black 45%, transparent 100%);
1892
+ -webkit-mask-image: radial-gradient(circle at center, black 45%, transparent 100%);
1893
+ opacity: 0.55;
1894
+ }
1895
+
1896
+ #app {
1897
+ isolation: isolate;
1898
+ position: relative;
1899
+ z-index: 1;
1900
+ }
1901
+
1902
+ .liquid-orb {
1903
+ position: fixed;
1904
+ border-radius: 999px;
1905
+ pointer-events: none;
1906
+ filter: blur(18px);
1907
+ mix-blend-mode: screen;
1908
+ opacity: 0.55;
1909
+ z-index: 0;
1910
+ animation: orb-float 16s ease-in-out infinite;
1911
+ }
1912
+
1913
+ .liquid-orb.orb-a {
1914
+ top: 78px;
1915
+ left: 42px;
1916
+ width: 220px;
1917
+ height: 220px;
1918
+ background: radial-gradient(circle at 30% 30%, rgba(129, 211, 255, 0.75), rgba(129, 211, 255, 0.12) 55%, transparent 75%);
1919
+ }
1920
+
1921
+ .liquid-orb.orb-b {
1922
+ top: 92px;
1923
+ right: 120px;
1924
+ width: 280px;
1925
+ height: 280px;
1926
+ background: radial-gradient(circle at 50% 50%, rgba(255, 144, 203, 0.42), rgba(255, 144, 203, 0.1) 58%, transparent 76%);
1927
+ animation-duration: 19s;
1928
+ }
1929
+
1930
+ .liquid-orb.orb-c {
1931
+ bottom: 66px;
1932
+ right: 22%;
1933
+ width: 240px;
1934
+ height: 240px;
1935
+ background: radial-gradient(circle at 50% 50%, rgba(54, 255, 204, 0.24), rgba(54, 255, 204, 0.08) 58%, transparent 78%);
1936
+ animation-duration: 22s;
1937
+ }
1938
+
1939
+ @keyframes orb-float {
1940
+
1941
+ 0%,
1942
+ 100% {
1943
+ transform: translate3d(0, 0, 0) scale(1);
1944
+ }
1945
+
1946
+ 50% {
1947
+ transform: translate3d(18px, -14px, 0) scale(1.06);
1948
+ }
1949
+ }
1950
+
1951
+ .cursor-aura,
1952
+ .cursor-dot {
1953
+ position: fixed;
1954
+ top: 0;
1955
+ left: 0;
1956
+ pointer-events: none;
1957
+ z-index: 9999;
1958
+ transform: translate3d(-50%, -50%, 0);
1959
+ transition: opacity 0.25s ease, transform 0.25s ease, width 0.25s ease, height 0.25s ease, background 0.25s ease;
1960
+ opacity: 0;
1961
+ }
1962
+
1963
+ .cursor-aura {
1964
+ width: 92px;
1965
+ height: 92px;
1966
+ border-radius: 50%;
1967
+ background: radial-gradient(circle, rgba(200, 242, 255, 0.84) 0%, rgba(143, 219, 255, 0.54) 34%, rgba(90, 189, 255, 0.24) 58%, rgba(90, 189, 255, 0.08) 72%, transparent 80%);
1968
+ filter: blur(12px);
1969
+ mix-blend-mode: screen;
1970
+ }
1971
+
1972
+ .cursor-dot {
1973
+ width: 30px;
1974
+ height: 30px;
1975
+ display: flex;
1976
+ align-items: center;
1977
+ justify-content: center;
1978
+ border-radius: 999px;
1979
+ font-family: var(--ff-display);
1980
+ font-size: 0.76rem;
1981
+ font-weight: 800;
1982
+ letter-spacing: 0.12em;
1983
+ color: #127ed4;
1984
+ text-shadow: 0 0 14px rgba(255, 255, 255, 0.9);
1985
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.96) 0%, rgba(216, 243, 255, 0.9) 58%, rgba(111, 202, 255, 0.28) 78%, transparent 82%);
1986
+ border: 1px solid rgba(86, 176, 255, 0.4);
1987
+ box-shadow: 0 0 26px rgba(71, 184, 255, 0.3);
1988
+ }
1989
+
1990
+ body.dark-theme .cursor-aura {
1991
+ width: 220px;
1992
+ height: 220px;
1993
+ background: radial-gradient(circle, rgba(202, 245, 255, 0.28) 0%, rgba(133, 225, 255, 0.18) 24%, rgba(76, 201, 255, 0.08) 44%, rgba(24, 96, 130, 0.03) 62%, transparent 74%);
1994
+ filter: blur(14px);
1995
+ mix-blend-mode: screen;
1996
+ }
1997
+
1998
+ body.dark-theme .cursor-dot {
1999
+ width: 30px;
2000
+ height: 30px;
2001
+ color: #f4fdff;
2002
+ text-shadow: 0 0 18px rgba(114, 226, 255, 0.95);
2003
+ background: radial-gradient(circle, rgba(255, 255, 255, 1) 0%, rgba(180, 239, 255, 0.96) 36%, rgba(86, 214, 255, 0.3) 70%, transparent 82%);
2004
+ border-color: rgba(133, 224, 255, 0.56);
2005
+ box-shadow: 0 0 26px rgba(76, 213, 255, 0.72);
2006
+ }
2007
+
2008
+ body.cursor-active .cursor-aura,
2009
+ body.cursor-active .cursor-dot {
2010
+ opacity: 1;
2011
+ }
2012
+
2013
+ body.cursor-press .cursor-aura {
2014
+ width: 120px;
2015
+ height: 120px;
2016
+ opacity: 0.8;
2017
+ }
2018
+
2019
+ body.dark-theme.cursor-press .cursor-aura {
2020
+ width: 250px;
2021
+ height: 250px;
2022
+ }
2023
+
2024
+ body.cursor-press .cursor-dot {
2025
+ transform: translate3d(-50%, -50%, 0) scale(0.82);
2026
+ }
2027
+
2028
+ body.cursor-hover .cursor-aura {
2029
+ width: 112px;
2030
+ height: 112px;
2031
+ background: radial-gradient(circle, rgba(214, 246, 255, 0.9) 0%, rgba(163, 227, 255, 0.58) 34%, rgba(97, 188, 255, 0.24) 58%, rgba(97, 188, 255, 0.08) 74%, transparent 82%);
2032
+ }
2033
+
2034
+ body.dark-theme.cursor-hover .cursor-aura {
2035
+ width: 250px;
2036
+ height: 250px;
2037
+ background: radial-gradient(circle, rgba(220, 247, 255, 0.32) 0%, rgba(144, 231, 255, 0.2) 26%, rgba(86, 214, 255, 0.09) 48%, rgba(24, 96, 130, 0.03) 66%, transparent 76%);
2038
+ }
2039
+
2040
+ .hdr,
2041
+ .analysis-panel,
2042
+ .explorer-window,
2043
+ .status-pill,
2044
+ .compact-gauge-card,
2045
+ .search-results,
2046
+ .gauge-hero-ai-details {
2047
+ box-shadow: var(--shadow-lg);
2048
+ }
2049
+
2050
+ .hdr {
2051
+ margin: 14px 14px 0;
2052
+ border-radius: 30px;
2053
+ border: 1px solid rgba(255, 255, 255, 0.42);
2054
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.62) 0%, rgba(255, 255, 255, 0.34) 48%, rgba(218, 231, 255, 0.2) 100%);
2055
+ backdrop-filter: blur(28px) saturate(180%);
2056
+ -webkit-backdrop-filter: blur(28px) saturate(180%);
2057
+ box-shadow: 0 20px 50px rgba(64, 98, 154, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.72);
2058
+ }
2059
+
2060
+ body.dark-theme .hdr {
2061
+ background: linear-gradient(135deg, rgba(13, 22, 43, 0.7) 0%, rgba(13, 22, 43, 0.48) 52%, rgba(13, 29, 61, 0.3) 100%);
2062
+ border-color: rgba(156, 211, 255, 0.12);
2063
+ box-shadow: 0 24px 60px rgba(2, 8, 22, 0.5), inset 0 1px 0 rgba(140, 205, 255, 0.08);
2064
+ }
2065
+
2066
+ .logo-name {
2067
+ background: linear-gradient(90deg, var(--txt-bright) 0%, var(--logo-primary) 38%, var(--logo-secondary) 80%, var(--txt-bright) 100%);
2068
+ background-size: 200% auto;
2069
+ animation: logo-sheen 7s linear infinite;
2070
+ }
2071
+
2072
+ @keyframes logo-sheen {
2073
+ 0% {
2074
+ background-position: 0% center;
2075
+ }
2076
+
2077
+ 100% {
2078
+ background-position: 200% center;
2079
+ }
2080
+ }
2081
+
2082
+ .market-status-bar {
2083
+ padding: 8px 12px;
2084
+ border-radius: 999px;
2085
+ background: rgba(255, 255, 255, 0.22);
2086
+ border: 1px solid rgba(255, 255, 255, 0.34);
2087
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.56);
2088
+ }
2089
+
2090
+ .omnibox {
2091
+ width: 250px;
2092
+ }
2093
+
2094
+ .omnibox input,
2095
+ .k-select,
2096
+ .k-input,
2097
+ .btn-icon,
2098
+ .btn-theme,
2099
+ .btn-primary,
2100
+ .dash-close,
2101
+ .explorer-search input,
2102
+ .explorer-symbol-card,
2103
+ .explorer-cat-item {
2104
+ position: relative;
2105
+ overflow: hidden;
2106
+ }
2107
+
2108
+ .omnibox input,
2109
+ .k-select,
2110
+ .k-input,
2111
+ .btn-icon,
2112
+ .btn-theme,
2113
+ .btn-primary,
2114
+ .dash-close,
2115
+ .explorer-search input {
2116
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.52) 0%, rgba(255, 255, 255, 0.28) 100%);
2117
+ border: 1px solid rgba(255, 255, 255, 0.44);
2118
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 14px 28px rgba(58, 92, 146, 0.08);
2119
+ backdrop-filter: blur(18px) saturate(175%);
2120
+ -webkit-backdrop-filter: blur(18px) saturate(175%);
2121
+ }
2122
+
2123
+ body.dark-theme .omnibox input,
2124
+ body.dark-theme .k-select,
2125
+ body.dark-theme .k-input,
2126
+ body.dark-theme .btn-icon,
2127
+ body.dark-theme .btn-theme,
2128
+ body.dark-theme .btn-primary,
2129
+ body.dark-theme .dash-close,
2130
+ body.dark-theme .explorer-search input {
2131
+ background: linear-gradient(180deg, rgba(18, 30, 58, 0.64) 0%, rgba(13, 23, 44, 0.4) 100%);
2132
+ border-color: rgba(136, 191, 255, 0.16);
2133
+ box-shadow: inset 0 1px 0 rgba(181, 224, 255, 0.08), 0 16px 34px rgba(0, 0, 0, 0.18);
2134
+ }
2135
+
2136
+ .omnibox input::placeholder,
2137
+ .explorer-search input::placeholder {
2138
+ color: color-mix(in srgb, var(--txt-muted) 78%, white 22%);
2139
+ }
2140
+
2141
+ .omnibox input:hover,
2142
+ .k-select:hover,
2143
+ .k-input:hover,
2144
+ .btn-icon:hover,
2145
+ .btn-theme:hover,
2146
+ .dash-close:hover,
2147
+ .explorer-search input:hover {
2148
+ transform: translateY(-1px);
2149
+ border-color: rgba(107, 173, 255, 0.52);
2150
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84), 0 18px 34px rgba(64, 104, 176, 0.12), 0 0 0 6px rgba(78, 156, 255, 0.06);
2151
+ }
2152
+
2153
+ .btn-primary {
2154
+ background: linear-gradient(135deg, rgba(18, 120, 255, 0.98) 0%, rgba(72, 173, 255, 0.88) 55%, rgba(111, 223, 255, 0.86) 100%);
2155
+ color: #f7fbff;
2156
+ border: 1px solid rgba(255, 255, 255, 0.4);
2157
+ box-shadow: 0 16px 34px rgba(34, 123, 255, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.4);
2158
+ letter-spacing: 0.18em;
2159
+ }
2160
+
2161
+ .btn-primary:hover {
2162
+ transform: translateY(-2px);
2163
+ box-shadow: 0 24px 44px rgba(34, 123, 255, 0.34), 0 0 0 7px rgba(78, 156, 255, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.48);
2164
+ filter: saturate(1.08);
2165
+ }
2166
+
2167
+ .btn-icon::before,
2168
+ .btn-theme::before,
2169
+ .btn-primary::before,
2170
+ .explorer-symbol-card::before {
2171
+ content: "";
2172
+ position: absolute;
2173
+ inset: 0;
2174
+ background: linear-gradient(115deg, transparent 20%, rgba(255, 255, 255, 0.42) 48%, transparent 76%);
2175
+ transform: translateX(-130%);
2176
+ transition: transform 0.7s ease;
2177
+ pointer-events: none;
2178
+ }
2179
+
2180
+ .btn-icon:hover::before,
2181
+ .btn-theme:hover::before,
2182
+ .btn-primary:hover::before,
2183
+ .explorer-symbol-card:hover::before {
2184
+ transform: translateX(130%);
2185
+ }
2186
+
2187
+ .main {
2188
+ margin: 14px;
2189
+ border-radius: 34px;
2190
+ border: 1px solid rgba(255, 255, 255, 0.42);
2191
+ background:
2192
+ radial-gradient(circle at top left, rgba(255, 255, 255, 0.3), transparent 30%),
2193
+ linear-gradient(180deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.12) 100%);
2194
+ box-shadow: 0 36px 90px rgba(31, 57, 102, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.78);
2195
+ backdrop-filter: blur(22px) saturate(180%);
2196
+ -webkit-backdrop-filter: blur(22px) saturate(180%);
2197
+ isolation: isolate;
2198
+ }
2199
+
2200
+ body.dark-theme .main {
2201
+ border-color: rgba(124, 179, 255, 0.1);
2202
+ background:
2203
+ radial-gradient(circle at top left, rgba(130, 177, 255, 0.08), transparent 30%),
2204
+ linear-gradient(180deg, rgba(9, 16, 34, 0.52) 0%, rgba(7, 14, 29, 0.32) 100%);
2205
+ box-shadow: 0 38px 100px rgba(1, 8, 20, 0.5), inset 0 1px 0 rgba(179, 224, 255, 0.08);
2206
+ }
2207
+
2208
+ .chart-bg-overlay {
2209
+ inset: 0;
2210
+ opacity: 1;
2211
+ background-image:
2212
+ linear-gradient(90deg, rgba(236, 246, 255, 0.9) 0%, rgba(236, 246, 255, 0.74) 24%, rgba(236, 246, 255, 0.34) 54%, rgba(236, 246, 255, 0.12) 100%),
2213
+ radial-gradient(circle at 24% 58%, rgba(104, 225, 255, 0.22), transparent 24%),
2214
+ radial-gradient(circle at 76% 14%, rgba(255, 171, 217, 0.16), transparent 20%),
2215
+ linear-gradient(180deg, rgba(134, 219, 255, 0.08) 0%, rgba(134, 219, 255, 0) 42%, rgba(124, 168, 255, 0.04) 100%);
2216
+ background-size: auto, auto, auto, auto;
2217
+ background-position: center, 24% 58%, 76% 14%, center;
2218
+ background-repeat: no-repeat;
2219
+ mask-image: linear-gradient(to right, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.96) 62%, rgba(0, 0, 0, 0.78) 82%, rgba(0, 0, 0, 0.42) 100%);
2220
+ -webkit-mask-image: linear-gradient(to right, rgba(0, 0, 0, 0.98) 0%, rgba(0, 0, 0, 0.96) 62%, rgba(0, 0, 0, 0.78) 82%, rgba(0, 0, 0, 0.42) 100%);
2221
+ }
2222
+
2223
+ .chart-logo-overlay {
2224
+ bottom: 28px;
2225
+ right: 108px;
2226
+ font-size: 3rem;
2227
+ letter-spacing: 0.35em;
2228
+ opacity: 0.16;
2229
+ z-index: 3;
2230
+ }
2231
+
2232
+ .chart-bg-overlay::before,
2233
+ .chart-bg-overlay::after {
2234
+ content: "";
2235
+ position: absolute;
2236
+ inset: 0;
2237
+ pointer-events: none;
2238
+ }
2239
+
2240
+ .chart-bg-overlay::before {
2241
+ background:
2242
+ linear-gradient(90deg, rgba(235, 246, 255, 0.28) 0%, rgba(235, 246, 255, 0.16) 35%, rgba(235, 246, 255, 0.06) 62%, transparent 100%),
2243
+ radial-gradient(circle at 31% 65%, rgba(92, 212, 255, 0.16), transparent 16%),
2244
+ radial-gradient(circle at 29% 34%, rgba(107, 173, 255, 0.12), transparent 18%),
2245
+ linear-gradient(90deg, transparent 0%, rgba(79, 179, 255, 0.08) 18%, transparent 36%, transparent 100%),
2246
+ var(--light-bg-image);
2247
+ background-size: auto, auto, auto, auto, cover;
2248
+ background-position: center, 31% 65%, 29% 34%, center, left center;
2249
+ background-repeat: no-repeat;
2250
+ mix-blend-mode: screen;
2251
+ opacity: 0.3;
2252
+ animation: hologram-drift 20s ease-in-out infinite;
2253
+ }
2254
+
2255
+ .chart-bg-overlay::after {
2256
+ background:
2257
+ linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.02) 38%, rgba(113, 225, 255, 0.04) 100%),
2258
+ radial-gradient(circle at 46% 58%, rgba(82, 221, 255, 0.12), transparent 7%),
2259
+ radial-gradient(circle at 72% 46%, rgba(126, 168, 255, 0.08), transparent 10%),
2260
+ repeating-linear-gradient(90deg, rgba(96, 182, 255, 0.06) 0 1px, transparent 1px 110px),
2261
+ repeating-linear-gradient(180deg, rgba(96, 182, 255, 0.04) 0 1px, transparent 1px 78px);
2262
+ opacity: 0.34;
2263
+ mask-image: linear-gradient(to right, rgba(0, 0, 0, 0.92) 0%, rgba(0, 0, 0, 0.6) 64%, transparent 100%);
2264
+ -webkit-mask-image: linear-gradient(to right, rgba(0, 0, 0, 0.92) 0%, rgba(0, 0, 0, 0.6) 64%, transparent 100%);
2265
+ animation: data-grid-drift 18s linear infinite, scan-sweep 8s ease-in-out infinite;
2266
+ }
2267
+
2268
+ body.dark-theme .chart-bg-overlay {
2269
+ background-image:
2270
+ linear-gradient(90deg, rgba(7, 15, 32, 0.76) 0%, rgba(7, 15, 32, 0.44) 28%, rgba(7, 15, 32, 0.14) 56%, rgba(7, 15, 32, 0.04) 100%),
2271
+ radial-gradient(circle at 22% 58%, rgba(71, 196, 255, 0.18), transparent 24%),
2272
+ radial-gradient(circle at 74% 16%, rgba(110, 146, 255, 0.12), transparent 18%),
2273
+ var(--dark-bg-image);
2274
+ background-size: auto, auto, auto, cover;
2275
+ background-position: center, 22% 58%, 74% 16%, left center;
2276
+ }
2277
+
2278
+ body.dark-theme .chart-bg-overlay::before {
2279
+ background:
2280
+ linear-gradient(90deg, rgba(18, 32, 64, 0.24) 0%, rgba(18, 32, 64, 0.12) 35%, rgba(18, 32, 64, 0.04) 62%, transparent 100%),
2281
+ radial-gradient(circle at 31% 65%, rgba(92, 212, 255, 0.16), transparent 16%),
2282
+ radial-gradient(circle at 29% 34%, rgba(107, 173, 255, 0.12), transparent 18%),
2283
+ linear-gradient(90deg, transparent 0%, rgba(79, 179, 255, 0.08) 18%, transparent 36%, transparent 100%),
2284
+ var(--dark-bg-image);
2285
+ background-size: auto, auto, auto, auto, cover;
2286
+ background-position: center, 31% 65%, 29% 34%, center, left center;
2287
+ background-repeat: no-repeat;
2288
+ opacity: 0.76;
2289
+ animation: hologram-drift 20s ease-in-out infinite;
2290
+ }
2291
+
2292
+ body.dark-theme .chart-bg-overlay::after {
2293
+ opacity: 0.28;
2294
+ }
2295
+
2296
+ @keyframes hologram-drift {
2297
+
2298
+ 0%,
2299
+ 100% {
2300
+ transform: translate3d(0, 0, 0) scale(1);
2301
+ filter: saturate(1) brightness(1);
2302
+ }
2303
+
2304
+ 50% {
2305
+ transform: translate3d(10px, -6px, 0) scale(1.018);
2306
+ filter: saturate(1.04) brightness(1.02);
2307
+ }
2308
+ }
2309
+
2310
+ @keyframes data-grid-drift {
2311
+ 0% {
2312
+ background-position: center, 46% 58%, 72% 46%, 0 0, 0 0;
2313
+ }
2314
+
2315
+ 100% {
2316
+ background-position: center, 47% 57%, 71% 47%, 110px 0, 0 78px;
2317
+ }
2318
+ }
2319
+
2320
+ @keyframes scan-sweep {
2321
+
2322
+ 0%,
2323
+ 100% {
2324
+ box-shadow: inset 0 0 0 rgba(95, 210, 255, 0);
2325
+ }
2326
+
2327
+ 50% {
2328
+ box-shadow: inset 0 -120px 120px rgba(95, 210, 255, 0.05), inset 0 120px 120px rgba(255, 255, 255, 0.03);
2329
+ }
2330
+ }
2331
+
2332
+ .status-pill {
2333
+ border-radius: 999px;
2334
+ padding: 10px 18px 10px 12px;
2335
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0.26) 100%);
2336
+ border: 1px solid rgba(255, 255, 255, 0.52);
2337
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82), 0 16px 32px rgba(42, 74, 129, 0.12);
2338
+ }
2339
+
2340
+ .chart-gauges-container {
2341
+ top: 54px;
2342
+ left: 20px;
2343
+ gap: 12px;
2344
+ }
2345
+
2346
+ .compact-gauge-card {
2347
+ min-width: 128px;
2348
+ padding: 12px 16px;
2349
+ border-radius: 22px;
2350
+ border: 1px solid rgba(255, 255, 255, 0.45);
2351
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.44) 0%, rgba(255, 255, 255, 0.18) 100%);
2352
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.76), 0 20px 36px rgba(56, 87, 138, 0.14);
2353
+ }
2354
+
2355
+ .compact-gauge-card:hover {
2356
+ transform: translateY(-4px) scale(1.015);
2357
+ }
2358
+
2359
+ .compact-gauge-card.hero {
2360
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.46) 0%, rgba(56, 148, 255, 0.2) 55%, rgba(111, 223, 255, 0.14) 100%);
2361
+ border-color: rgba(104, 181, 255, 0.5);
2362
+ }
2363
+
2364
+ .analysis-panel {
2365
+ inset: 14px;
2366
+ width: auto;
2367
+ height: auto;
2368
+ border-radius: 34px;
2369
+ border: 1px solid rgba(255, 255, 255, 0.44);
2370
+ background: linear-gradient(180deg, rgba(245, 249, 255, 0.7) 0%, rgba(234, 241, 251, 0.52) 100%);
2371
+ box-shadow: 0 36px 100px rgba(33, 56, 98, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.84);
2372
+ overflow: hidden;
2373
+ }
2374
+
2375
+ body.dark-theme .analysis-panel {
2376
+ border-color: rgba(148, 208, 255, 0.12);
2377
+ background: linear-gradient(180deg, rgba(8, 15, 30, 0.78) 0%, rgba(8, 15, 30, 0.6) 100%);
2378
+ }
2379
+
2380
+ .dash-header {
2381
+ padding: 24px 34px;
2382
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.32) 0%, rgba(255, 255, 255, 0.12) 100%);
2383
+ border-bottom: 1px solid rgba(255, 255, 255, 0.26);
2384
+ }
2385
+
2386
+ .dash-gauges-hero {
2387
+ gap: 18px;
2388
+ padding: 18px 18px 0;
2389
+ border-bottom: none;
2390
+ }
2391
+
2392
+ .gauge-hero-card {
2393
+ border-right: none;
2394
+ border-radius: 28px;
2395
+ margin-bottom: 18px;
2396
+ border: 1px solid rgba(255, 255, 255, 0.34);
2397
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.12) 100%);
2398
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 22px 38px rgba(52, 86, 143, 0.12);
2399
+ }
2400
+
2401
+ .gauge-hero-card:hover {
2402
+ transform: translateY(-6px) scale(1.01);
2403
+ border-color: rgba(108, 180, 255, 0.4);
2404
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 30px 46px rgba(49, 87, 152, 0.18), 0 0 0 7px rgba(103, 178, 255, 0.06);
2405
+ }
2406
+
2407
+ .gauge-hero-card.hero-total {
2408
+ background: linear-gradient(160deg, rgba(255, 255, 255, 0.28) 0%, rgba(57, 144, 255, 0.18) 58%, rgba(111, 223, 255, 0.14) 100%);
2409
+ }
2410
+
2411
+ .gauge-hero-title,
2412
+ .dc-header,
2413
+ .explorer-title {
2414
+ letter-spacing: 0.18em;
2415
+ }
2416
+
2417
+ .gauge-hero-ai-details,
2418
+ .dc-header,
2419
+ .dash-col,
2420
+ .explorer-sidebar,
2421
+ .explorer-main,
2422
+ .explorer-symbol-card,
2423
+ .search-results {
2424
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.14) 100%);
2425
+ backdrop-filter: blur(18px) saturate(170%);
2426
+ -webkit-backdrop-filter: blur(18px) saturate(170%);
2427
+ }
2428
+
2429
+ .dash-tables-row {
2430
+ padding: 0 18px 18px;
2431
+ gap: 18px;
2432
+ background: transparent;
2433
+ transition: grid-template-columns 0.32s ease, opacity 0.24s ease, gap 0.24s ease;
2434
+ }
2435
+
2436
+ .dash-col {
2437
+ border-right: none;
2438
+ border-radius: 24px;
2439
+ border: 1px solid rgba(255, 255, 255, 0.34);
2440
+ overflow: visible;
2441
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 20px 36px rgba(55, 86, 138, 0.1);
2442
+ transition: transform 0.28s ease, opacity 0.28s ease, box-shadow 0.28s ease, border-color 0.28s ease, filter 0.28s ease;
2443
+ cursor: pointer;
2444
+ }
2445
+
2446
+ .dash-col:hover {
2447
+ transform: translateY(-3px);
2448
+ border-color: rgba(104, 176, 255, 0.44);
2449
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 24px 42px rgba(55, 92, 150, 0.16);
2450
+ }
2451
+
2452
+ .dash-col.is-focus {
2453
+ transform: translateY(-6px);
2454
+ border-color: rgba(95, 171, 255, 0.56);
2455
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84), 0 30px 54px rgba(48, 89, 160, 0.2), 0 0 0 8px rgba(100, 175, 255, 0.08);
2456
+ filter: saturate(1.06);
2457
+ z-index: 2;
2458
+ }
2459
+
2460
+ .dash-tables-row.has-focus {
2461
+ grid-template-columns: minmax(0, 1fr);
2462
+ gap: 0;
2463
+ overflow: visible;
2464
+ }
2465
+
2466
+ .dash-tables-row.has-focus .dash-col:not(.is-focus) {
2467
+ opacity: 0;
2468
+ filter: blur(6px) saturate(0.8);
2469
+ transform: scale(0.96);
2470
+ pointer-events: none;
2471
+ max-width: 0;
2472
+ min-width: 0;
2473
+ border-width: 0;
2474
+ margin: 0;
2475
+ padding: 0;
2476
+ height: 0;
2477
+ }
2478
+
2479
+ .dash-tables-row.has-focus .dash-col.is-focus {
2480
+ width: 100%;
2481
+ }
2482
+
2483
+ .dash-tables-row.has-focus .dash-table-wrap {
2484
+ overflow: visible;
2485
+ }
2486
+
2487
+ .dt td,
2488
+ .pivot-table td,
2489
+ .pivot-table th {
2490
+ border-bottom-color: rgba(140, 173, 219, 0.16);
2491
+ }
2492
+
2493
+ .dt tr:hover td,
2494
+ .pivot-table tr:hover td {
2495
+ background: rgba(102, 176, 255, 0.06);
2496
+ }
2497
+
2498
+ .dc-header::after {
2499
+ content: "Click để làm rõ";
2500
+ float: right;
2501
+ font-size: 0.62rem;
2502
+ letter-spacing: 0.12em;
2503
+ color: var(--txt-muted);
2504
+ opacity: 0.85;
2505
+ }
2506
+
2507
+ .dash-col.is-focus .dc-header::after {
2508
+ content: "Đang tập trung";
2509
+ color: var(--accent);
2510
+ }
2511
+
2512
+ .dt-act-neut,
2513
+ .compact-gauge-signal.neutral {
2514
+ color: #facc15 !important;
2515
+ text-shadow: 0 0 14px rgba(250, 204, 21, 0.18);
2516
+ }
2517
+
2518
+ .dash-col.is-focus .dc-header::after {
2519
+ content: "Nh\1EA5n l\1EA7n n\1EEF a \0111\1EC3 thu g\1ECDn";
2520
+ }
2521
+
2522
+ .explorer-overlay {
2523
+ background: rgba(9, 14, 28, 0.34);
2524
+ backdrop-filter: blur(28px) saturate(155%);
2525
+ }
2526
+
2527
+ .explorer-window {
2528
+ max-width: 1220px;
2529
+ max-height: 780px;
2530
+ border-radius: 34px;
2531
+ background: linear-gradient(180deg, rgba(245, 249, 255, 0.72) 0%, rgba(233, 239, 250, 0.56) 100%);
2532
+ border: 1px solid rgba(255, 255, 255, 0.46);
2533
+ box-shadow: 0 40px 110px rgba(18, 34, 66, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.84);
2534
+ backdrop-filter: blur(26px) saturate(180%);
2535
+ -webkit-backdrop-filter: blur(26px) saturate(180%);
2536
+ }
2537
+
2538
+ body.dark-theme .explorer-window {
2539
+ background: linear-gradient(180deg, rgba(9, 16, 34, 0.76) 0%, rgba(8, 15, 30, 0.62) 100%);
2540
+ border-color: rgba(144, 198, 255, 0.12);
2541
+ }
2542
+
2543
+ .explorer-head,
2544
+ .explorer-main {
2545
+ background: transparent;
2546
+ }
2547
+
2548
+ .explorer-sidebar {
2549
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.12) 100%);
2550
+ }
2551
+
2552
+ .explorer-cat-item {
2553
+ margin: 0 12px 8px;
2554
+ border-radius: 16px;
2555
+ border: 1px solid transparent;
2556
+ border-left: 1px solid transparent;
2557
+ }
2558
+
2559
+ .explorer-cat-item.active {
2560
+ border-color: rgba(107, 177, 255, 0.35);
2561
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 10px 20px rgba(59, 118, 196, 0.12);
2562
+ }
2563
+
2564
+ .explorer-grid {
2565
+ gap: 16px;
2566
+ }
2567
+
2568
+ .explorer-symbol-card {
2569
+ border-radius: 22px;
2570
+ border: 1px solid rgba(255, 255, 255, 0.38);
2571
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0.14) 100%);
2572
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.74), 0 18px 34px rgba(52, 84, 136, 0.12);
2573
+ transition: transform 0.28s ease, box-shadow 0.28s ease, border-color 0.28s ease;
2574
+ }
2575
+
2576
+ .explorer-symbol-card:hover {
2577
+ transform: translateY(-6px) rotateX(3deg);
2578
+ border-color: rgba(98, 172, 255, 0.44);
2579
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82), 0 28px 44px rgba(39, 95, 174, 0.18), 0 0 0 8px rgba(90, 165, 255, 0.06);
2580
+ }
2581
+
2582
+ @media (max-width: 1024px) {
2583
+ .hdr {
2584
+ margin: 10px 10px 0;
2585
+ border-radius: 24px;
2586
+ }
2587
+
2588
+ .main,
2589
+ .analysis-panel {
2590
+ margin: 10px;
2591
+ border-radius: 26px;
2592
+ }
2593
+
2594
+ .omnibox {
2595
+ width: 180px;
2596
+ }
2597
+
2598
+ .chart-gauges-container {
2599
+ flex-wrap: wrap;
2600
+ right: 18px;
2601
+ top: 62px;
2602
+ }
2603
+ }
2604
+
2605
+ @media (max-width: 800px) {
2606
+
2607
+ .cursor-aura,
2608
+ .cursor-dot,
2609
+ .liquid-orb {
2610
+ display: none;
2611
+ }
2612
+
2613
+ .hdr {
2614
+ padding: 0 12px;
2615
+ }
2616
+
2617
+ .main,
2618
+ .analysis-panel {
2619
+ margin: 8px;
2620
+ border-radius: 22px;
2621
+ }
2622
+
2623
+ .dash-gauges-hero,
2624
+ .dash-tables-row {
2625
+ padding-left: 12px;
2626
+ padding-right: 12px;
2627
+ }
2628
+
2629
+ .compact-gauge-card {
2630
+ min-width: 112px;
2631
+ }
2632
+
2633
+ .chart-logo-overlay {
2634
+ font-size: 1.8rem;
2635
+ right: 72px;
2636
+ }
2637
+
2638
+ .chart-gauges-container {
2639
+ top: 68px;
2640
+ }
2641
+ }
2642
  </style>
2643
  </head>
2644
 
2645
  <body>
2646
+ <div class="liquid-orb orb-a" aria-hidden="true"></div>
2647
+ <div class="liquid-orb orb-b" aria-hidden="true"></div>
2648
+ <div class="liquid-orb orb-c" aria-hidden="true"></div>
2649
+ <div class="cursor-aura" id="cursorAura" aria-hidden="true"></div>
2650
+ <div class="cursor-dot" id="cursorDot" aria-hidden="true">AI</div>
2651
  <div id="app">
2652
 
2653
  <!-- ── HEADER ───────────────────────────────── -->
 
2656
  <!-- Logo -->
2657
  <div class="logo">
2658
  <div class="logo-mark">
2659
+ <svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
2660
+ <defs>
2661
+ <linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
2662
+ <stop offset="0%" stop-color="var(--logo-primary)" />
2663
+ <stop offset="100%" stop-color="var(--logo-secondary)" />
2664
+ </linearGradient>
2665
+ <filter id="logoGlow" x="-20%" y="-20%" width="140%" height="140%">
2666
+ <feGaussianBlur stdDeviation="2" result="blur" />
2667
+ <feComposite in="SourceGraphic" in2="blur" operator="over" />
2668
+ </filter>
2669
+ </defs>
2670
+
2671
+ <!-- Circuit Lines (Outer) -->
2672
+ <g stroke="url(#logoGrad)" stroke-width="1.2" stroke-linecap="round" opacity="0.8">
2673
+ <!-- Top -->
2674
+ <path d="M50 25 V10" />
2675
+ <circle cx="50" cy="8" r="2.5" fill="none" stroke="url(#logoGrad)" stroke-width="1" />
2676
+ <circle cx="50" cy="8" r="1.2" fill="url(#logoGrad)" />
2677
+
2678
+ <path d="M42 28 V15 H35" />
2679
+ <circle cx="33" cy="15" r="1.5" fill="url(#logoGrad)" />
2680
+
2681
+ <path d="M58 28 V15 H65" />
2682
+ <circle cx="67" cy="15" r="1.5" fill="url(#logoGrad)" />
2683
+
2684
+ <!-- Bottom -->
2685
+ <path d="M50 75 V90" />
2686
+ <circle cx="50" cy="92" r="2.5" fill="none" stroke="url(#logoGrad)" stroke-width="1" />
2687
+ <circle cx="50" cy="92" r="1.2" fill="url(#logoGrad)" />
2688
+
2689
+ <path d="M42 72 V85 H35" />
2690
+ <circle cx="33" cy="85" r="1.5" fill="url(#logoGrad)" />
2691
+
2692
+ <path d="M58 72 V85 H65" />
2693
+ <circle cx="67" cy="85" r="1.5" fill="url(#logoGrad)" />
2694
+
2695
+ <!-- Left -->
2696
+ <path d="M25 50 H10" />
2697
+ <circle cx="8" cy="50" r="2" fill="url(#logoGrad)" />
2698
+
2699
+ <path d="M28 42 H15 V35" />
2700
+ <circle cx="15" cy="33" r="1.5" fill="url(#logoGrad)" />
2701
+
2702
+ <path d="M28 58 H15 V65" />
2703
+ <circle cx="15" cy="67" r="1.5" fill="url(#logoGrad)" />
2704
+
2705
+ <!-- Right -->
2706
+ <path d="M75 50 H90" />
2707
+ <circle cx="92" cy="50" r="2" fill="url(#logoGrad)" />
2708
+
2709
+ <path d="M72 42 H85 V35" />
2710
+ <circle cx="85" cy="33" r="1.5" fill="url(#logoGrad)" />
2711
+
2712
+ <path d="M72 58 H85 V65" />
2713
+ <circle cx="85" cy="67" r="1.5" fill="url(#logoGrad)" />
2714
+ </g>
2715
+
2716
+ <!-- Central Chip Background Glow -->
2717
+ <rect x="28" y="28" width="44" height="44" rx="10" fill="var(--logo-glow)" opacity="0.15"
2718
+ filter="url(#logoGlow)" />
2719
+
2720
+ <!-- Central Chip Frame -->
2721
+ <rect x="30" y="30" width="40" height="40" rx="8" stroke="url(#logoGrad)" stroke-width="2.5"
2722
+ fill="var(--bg-depth)" />
2723
+ <rect x="34" y="34" width="32" height="32" rx="4" stroke="url(#logoGrad)" stroke-width="0.8" opacity="0.3"
2724
  fill="none" />
2725
+
2726
+ <!-- "AI" Text -->
2727
+ <text x="50" y="57" text-anchor="middle" fill="url(#logoGrad)" font-family="var(--ff-display)"
2728
+ font-weight="900" font-size="22" style="letter-spacing: 0.05em;">AI</text>
2729
+
2730
+ <!-- Connectors -->
2731
+ <path d="M30 40 H25 M30 50 H25 M30 60 H25" stroke="url(#logoGrad)" stroke-width="1.2" />
2732
+ <path d="M70 40 H75 M70 50 H75 M70 60 H75" stroke="url(#logoGrad)" stroke-width="1.2" />
2733
+ <path d="M40 30 V25 M50 30 V25 M60 30 V25" stroke="url(#logoGrad)" stroke-width="1.2" />
2734
+ <path d="M40 70 V75 M50 70 V75 M60 70 V75" stroke="url(#logoGrad)" stroke-width="1.2" />
2735
  </svg>
2736
  </div>
2737
  <!-- Market Status -->
 
2766
  <div class="ctrl-unit">
2767
  <span class="ctrl-label">Khung thời gian</span>
2768
  <select class="k-select" id="timeframeSelect">
2769
+ <option disabled selected hidden>TimeFrame</option>
2770
  <option>1m</option>
2771
  <option>5m</option>
2772
  <option>15m</option>
 
2779
 
2780
  <div class="ctrl-unit">
2781
  <span class="ctrl-label">Dự báo (nến)</span>
2782
+ <input class="k-input" id="horizonInput" type="number" min="5" max="300" value="10" placeholder="Forecast" />
2783
  </div>
2784
 
2785
  <div class="ctrl-unit">
2786
  <span class="ctrl-label">Chỉ báo</span>
2787
  <select class="k-select" id="indicatorSelect" style="width: 140px;">
2788
+ <option disabled selected hidden>Indicator</option>
2789
  <option value="none">Không có</option>
2790
  <option value="bb">Bollinger Bands</option>
2791
  <option value="rsi">RSI (14)</option>
 
2915
  const analysisPanel = document.getElementById('analysisPanel');
2916
  const marketStatusBar = document.getElementById('marketStatusBar');
2917
  const indicatorSelect = document.getElementById('indicatorSelect');
2918
+ const cursorAura = document.getElementById('cursorAura');
2919
+ const cursorDot = document.getElementById('cursorDot');
2920
 
2921
  /* ── State ─────────────────────────────────── */
2922
  let currentSymbol = 'XAUUSD';
 
2931
 
2932
  /* ── Indicator Calculation Helpers ────────── */
2933
  /* ── WebSocket Management ────────────────────── */
2934
+ (() => {
2935
+ if (!cursorAura || !cursorDot || window.matchMedia('(pointer: coarse)').matches) return;
2936
+
2937
+ let mouseX = window.innerWidth / 2;
2938
+ let mouseY = window.innerHeight / 2;
2939
+ let auraX = mouseX;
2940
+ let auraY = mouseY;
2941
+ let rafId = 0;
2942
+ const interactiveSelector = 'button, input, select, .search-item, .compact-gauge-card, .explorer-symbol-card, .explorer-cat-item, .dash-close, .dash-col';
2943
+
2944
+ function renderCursor() {
2945
+ auraX += (mouseX - auraX) * 0.14;
2946
+ auraY += (mouseY - auraY) * 0.14;
2947
+ cursorAura.style.transform = `translate3d(${auraX}px, ${auraY}px, 0) translate(-50%, -50%)`;
2948
+ cursorDot.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0) translate(-50%, -50%)`;
2949
+ rafId = requestAnimationFrame(renderCursor);
2950
+ }
2951
+
2952
+ document.addEventListener('mousemove', (event) => {
2953
+ mouseX = event.clientX;
2954
+ mouseY = event.clientY;
2955
+ document.body.classList.add('cursor-active');
2956
+ if (!rafId) rafId = requestAnimationFrame(renderCursor);
2957
+ }, { passive: true });
2958
+
2959
+ document.addEventListener('mouseleave', () => {
2960
+ document.body.classList.remove('cursor-active');
2961
+ });
2962
+
2963
+ document.addEventListener('mousedown', () => {
2964
+ document.body.classList.add('cursor-press');
2965
+ });
2966
+
2967
+ document.addEventListener('mouseup', () => {
2968
+ document.body.classList.remove('cursor-press');
2969
+ });
2970
+
2971
+ document.addEventListener('mouseover', (event) => {
2972
+ const target = event.target instanceof Element ? event.target.closest(interactiveSelector) : null;
2973
+ document.body.classList.toggle('cursor-hover', Boolean(target));
2974
+ });
2975
+ })();
2976
+
2977
  function connectWS(symbol) {
2978
  if (ws) {
2979
  ws.close();
 
3156
  });
3157
 
3158
  const p50Series = chart.addLineSeries({
3159
+ color: '#66d9ff',
3160
  lineWidth: 2,
3161
  title: 'Dự báo AI',
3162
  priceLineVisible: false,
3163
+ lastValueVisible: false,
3164
  visible: false,
3165
  });
3166
 
3167
  const p10Series = chart.addLineSeries({
3168
+ color: 'rgba(102, 217, 255, 0.18)',
3169
+ lineWidth: 2,
3170
  lineStyle: LightweightCharts.LineStyle.Dashed,
3171
  priceLineVisible: false,
3172
  lastValueVisible: false,
3173
  visible: false,
3174
  });
3175
 
3176
+ let forecastSegmentSeries = [];
3177
+
3178
+ function clearForecastSegments() {
3179
+ if (!forecastSegmentSeries.length) return;
3180
+ for (const series of forecastSegmentSeries) {
3181
+ try {
3182
+ chart.removeSeries(series);
3183
+ } catch (e) {
3184
+ console.warn('[forecastSegments] remove failed', e);
3185
+ }
3186
+ }
3187
+ forecastSegmentSeries = [];
3188
+ }
3189
+
3190
+ function buildForecastSegmentSeries(points) {
3191
+ clearForecastSegments();
3192
+ if (!Array.isArray(points) || points.length < 2) return;
3193
+
3194
+ const EPSILON = 0.0001;
3195
+ for (let i = 1; i < points.length; i += 1) {
3196
+ const prev = points[i - 1];
3197
+ const curr = points[i];
3198
+ const diff = (curr?.value ?? 0) - (prev?.value ?? 0);
3199
+ const color = diff > EPSILON ? '#45a9ff' : diff < -EPSILON ? '#ff6b7a' : '#f6c94a';
3200
+ const segSeries = chart.addLineSeries({
3201
+ color,
3202
+ lineWidth: 2,
3203
+ priceLineVisible: false,
3204
+ lastValueVisible: false,
3205
+ crosshairMarkerVisible: false,
3206
+ });
3207
+ segSeries.setData([prev, curr]);
3208
+ forecastSegmentSeries.push(segSeries);
3209
+ }
3210
+ }
3211
+
3212
  const p90Series = chart.addLineSeries({
3213
+ color: 'rgba(102, 217, 255, 0.18)',
3214
+ lineWidth: 2,
3215
  lineStyle: LightweightCharts.LineStyle.Dashed,
3216
  priceLineVisible: false,
3217
  lastValueVisible: false,
 
3291
  const angle = score * 135;
3292
  const cx = w / 2, cy = h * 0.65, r = (w / 2) * 0.62;
3293
  const strokeW = w > 150 ? 16 : 8;
3294
+
3295
  function arc(s, e, col) {
3296
  const sa = (s - 90) * Math.PI / 180, ea = (e - 90) * Math.PI / 180;
3297
  const x1 = cx + r * Math.cos(sa), y1 = cy + r * Math.sin(sa), x2 = cx + r * Math.cos(ea), y2 = cy + r * Math.sin(ea);
3298
  return `<path d="M${x1},${y1} A${r},${r} 0 ${(e - s) > 180 ? 1 : 0} 1 ${x2},${y2}" fill="none" stroke="${col}" stroke-width="${strokeW}" stroke-linecap="round" opacity="0.8"/>`;
3299
  }
3300
+
3301
  const na = (angle - 90) * Math.PI / 180, nl = r + 2;
3302
  const nx = cx + nl * Math.cos(na), ny = cy + nl * Math.sin(na);
3303
+
3304
+ // Color logic: Red -> Yellow -> Green
3305
+ let needleColor = '#facc15'; // Yellow (Neutral/Default)
3306
+ if (displayValue > 70) needleColor = '#22c55e'; // Green
3307
+ else if (displayValue < 40) needleColor = '#ef4444'; // Red
3308
 
3309
  let valueHtml = '';
3310
  if (showValue) {
 
3334
  `;
3335
  }
3336
 
3337
+ function gaugeToRawScore(gauge) {
3338
+ const value = Number(gauge);
3339
+ if (!Number.isFinite(value)) return 0;
3340
+ return Math.max(-1, Math.min(1, (value - 50) / 50));
3341
+ }
3342
+
3343
  function getSignalClass(signal) {
3344
+ if (!signal) return 'neutral';
3345
  if (signal.includes('Mua mạnh')) return 'strong-buy';
3346
  if (signal.includes('Mua')) return 'buy';
3347
  if (signal.includes('Bán mạnh')) return 'strong-sell';
 
3357
  }
3358
 
3359
  const a = payload.analysis;
3360
+ const dashboard = a.dashboard || {};
3361
+ const technical = dashboard.technical || a.technicals || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 };
3362
+ const ai = dashboard.ai || a.ai_gauge || { gauge: 50, signal: '--' };
3363
+ const summary = dashboard.summary || a.summary || { gauge: 50, signal: '--' };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3364
 
3365
  container.innerHTML = `
3366
+ <div class="compact-gauge-card" onclick="refreshBtn.click()" style="cursor:pointer">
3367
  <div class="compact-gauge-title">Kỹ thuật</div>
3368
+ <div class="compact-gauge-svg-wrap">${buildGaugeSvg(gaugeToRawScore(technical.gauge), 80, 50, false)}</div>
3369
+ <div class="compact-gauge-signal ${getSignalClass(technical.signal)}">${technical.signal}</div>
3370
  </div>
3371
+ <div class="compact-gauge-card" onclick="refreshBtn.click()" style="cursor:pointer">
3372
  <div class="compact-gauge-title">Dự báo AI</div>
3373
+ <div class="compact-gauge-svg-wrap">${buildGaugeSvg(gaugeToRawScore(ai.gauge), 80, 50, false)}</div>
3374
+ <div class="compact-gauge-signal ${getSignalClass(ai.signal)}">${ai.signal}</div>
3375
  </div>
3376
+ <div class="compact-gauge-card hero" onclick="refreshBtn.click()" style="cursor:pointer">
3377
  <div class="compact-gauge-title">Tổng kết</div>
3378
+ <div class="compact-gauge-svg-wrap">${buildGaugeSvg(gaugeToRawScore(summary.gauge), 80, 50, false)}</div>
3379
+ <div class="compact-gauge-signal ${getSignalClass(summary.signal)}">${summary.signal}</div>
3380
  </div>
3381
  `;
3382
  }
 
3414
  const osc = a.oscillators || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
3415
  const ma = a.moving_averages || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
3416
  const summary = a.summary || { sell: 0, neutral: 0, buy: 0, signal: '--' };
3417
+ const technicals = a.technicals || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 };
3418
+ const aiGauge = a.ai_gauge || { gauge: 50, signal: '--', confidence_pct: 0, certainty: 0, path_consistency: 50 };
3419
+ const dashboard = a.dashboard || {};
3420
  const pivots = (a.pivot_points || {}).data || [];
3421
 
 
 
3422
  const forecastRows = payload.forecast || [];
3423
  const lastClose = payload.last_close || 0;
3424
+ const aiCurrentPrice = dashboard.ai?.current_price ?? lastClose;
3425
+ const forecastEnd = dashboard.ai?.forecast_price ?? (forecastRows.length > 1 ? (forecastRows[forecastRows.length - 1]?.p50 ?? lastClose) : lastClose);
3426
+ const forecastPctChange = dashboard.ai?.forecast_return_pct ?? (lastClose > 0 ? ((forecastEnd - lastClose) / lastClose) * 100 : 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3427
 
3428
  // ── Big SVG Gauge builder (Refactored to buildGaugeSvg) ──
3429
 
 
3468
  <div class="gauge-hero-card">
3469
  <div class="gauge-hero-title">PHÂN TÍCH KỸ THUẬT</div>
3470
  <div class="gauge-hero-svg-wrap">
3471
+ ${buildGaugeSvg(gaugeToRawScore(technicals.gauge))}
3472
  </div>
3473
+ <div class="gauge-hero-signal ${signalClass(technicals.signal)}">${technicals.signal}</div>
3474
  <div class="gauge-hero-counts">
3475
+ <span><span class="ghc-label">Bán</span><span class="ghc-value">${technicals.sell}</span></span>
3476
+ <span><span class="ghc-label">Trung lập</span><span class="ghc-value">${technicals.neutral}</span></span>
3477
+ <span><span class="ghc-label">Mua</span><span class="ghc-value">${technicals.buy}</span></span>
3478
  </div>
3479
  </div>
3480
 
 
3482
  <div class="gauge-hero-card">
3483
  <div class="gauge-hero-title">DỰ BÁO AI</div>
3484
  <div class="gauge-hero-svg-wrap">
3485
+ ${buildGaugeSvg(gaugeToRawScore(aiGauge.gauge))}
3486
  </div>
3487
  <div class="gauge-hero-ai-details">
3488
  <div class="gh-ai-row">
3489
  <span class="gh-ai-label">Hiện tại:</span>
3490
+ <span class="gh-ai-val">${formatPrice(aiCurrentPrice)}</span>
3491
  </div>
3492
  <div class="gh-ai-row">
3493
  <span class="gh-ai-label">Dự kiến:</span>
 
3497
  <span class="gh-ai-label">Biến động:</span>
3498
  <span class="gh-ai-val ${forecastPctChange >= 0 ? 'up' : 'down'}">${forecastPctChange >= 0 ? '↑' : '↓'} ${Math.abs(forecastPctChange).toFixed(2)}%</span>
3499
  </div>
3500
+ <div class="gh-ai-row">
3501
+ <span class="gh-ai-label">Độ chắc chắn:</span>
3502
+ <span class="gh-ai-val">${Number(aiGauge.certainty ?? 0).toFixed(1)}%</span>
3503
+ </div>
3504
+ <div class="gh-ai-row">
3505
+ <span class="gh-ai-label">Độ ổn định đường đi:</span>
3506
+ <span class="gh-ai-val">${Number(aiGauge.path_consistency ?? 0).toFixed(1)}%</span>
3507
+ </div>
3508
  </div>
3509
+ <div class="gauge-hero-signal ${signalClass(aiGauge.signal)}">${aiGauge.signal}</div>
3510
  </div>
3511
 
3512
  <!-- Gauge 3: TỔNG KẾT -->
3513
  <div class="gauge-hero-card hero-total">
3514
  <div class="gauge-hero-title title-total">⚡ TỔNG KẾT</div>
3515
  <div class="gauge-hero-svg-wrap">
3516
+ ${buildGaugeSvg(gaugeToRawScore(summary.gauge))}
3517
  </div>
3518
+ <div class="gauge-hero-signal signal-total ${signalClass(summary.signal)}">${summary.signal}</div>
3519
  </div>
3520
 
3521
  </div>
 
3524
  <div class="dash-tables-row">
3525
 
3526
  <!-- Oscillators -->
3527
+ <div class="dash-col" data-focus-panel="osc">
3528
+ <div class="dc-header">Chỉ báo Kỹ thuật</div>
3529
  <div class="dash-table-wrap">
3530
  <table class="dt"><tbody>${oscRows}</tbody></table>
3531
  </div>
3532
  </div>
3533
 
3534
  <!-- Moving Averages -->
3535
+ <div class="dash-col" data-focus-panel="ma">
3536
  <div class="dc-header">Trung bình trượt</div>
3537
  <div class="dash-table-wrap">
3538
  <table class="dt"><tbody>${maRows}</tbody></table>
 
3540
  </div>
3541
 
3542
  <!-- Pivot Points -->
3543
+ <div class="dash-col col-pivots" data-focus-panel="pivots">
3544
  <div class="dc-header">Điểm xoay</div>
3545
  <div class="dash-table-wrap">
3546
  <table class="pivot-table">
 
3562
  // Close logic
3563
  const closeBtn = document.getElementById('dashCloseBtn');
3564
  if (closeBtn) closeBtn.onclick = () => analysisPanel.classList.remove('active');
3565
+
3566
+ const tablesRow = analysisPanel.querySelector('.dash-tables-row');
3567
+ const focusCols = Array.from(analysisPanel.querySelectorAll('.dash-col[data-focus-panel]'));
3568
+ focusCols.forEach((col) => {
3569
+ col.onclick = () => {
3570
+ const key = col.getAttribute('data-focus-panel');
3571
+ const alreadyFocused = col.classList.contains('is-focus');
3572
+ focusCols.forEach((item) => item.classList.remove('is-focus'));
3573
+ tablesRow?.classList.remove('has-focus', 'focus-osc', 'focus-ma', 'focus-pivots');
3574
+ if (!alreadyFocused && key && tablesRow) {
3575
+ col.classList.add('is-focus');
3576
+ tablesRow.classList.add('has-focus', `focus-${key}`);
3577
+ requestAnimationFrame(() => col.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
3578
+ }
3579
+ };
3580
+ });
3581
  }
3582
 
3583
 
 
3588
  p50Series.setData([]);
3589
  p10Series.setData([]);
3590
  p90Series.setData([]);
3591
+ clearForecastSegments();
3592
 
3593
  p50Series.applyOptions({ visible: false });
3594
  p10Series.applyOptions({ visible: false });
 
3748
  let lastForecastVal = 0;
3749
 
3750
  if (fData.error || !fData.forecast || fData.forecast.length === 0) {
3751
+ clearForecastSegments();
3752
+ p50Series.setData([]);
3753
+ p50Series.applyOptions({ visible: false });
3754
+ p10Series.setData([]);
3755
+ p90Series.setData([]);
3756
+ p10Series.applyOptions({ visible: false });
3757
+ p90Series.applyOptions({ visible: false });
3758
  updateStatus('AI: ' + (fData.error || 'Thiếu dữ liệu dự báo'), 'warning');
3759
  } else {
3760
  // Safeguard: Ensure we have candle data before aligning
 
3763
  return;
3764
  }
3765
 
3766
+ const forecastPoints = fData.forecast;
3767
+ const anchorPoint = { time: lastCandleData.time, value: lastCandleData.close };
3768
+ const futurePoints = forecastPoints
3769
+ .filter(d => d && d.time !== undefined && d.p50 !== undefined && d.time !== lastCandleData.time)
3770
+ .map(d => ({ time: d.time, value: d.p50 }));
3771
+ const p50 = [anchorPoint, ...futurePoints];
3772
+
3773
+ p50Series.setData([]);
3774
+ p50Series.applyOptions({ visible: false });
3775
+ p10Series.setData([]);
3776
+ p90Series.setData([]);
3777
+ p10Series.applyOptions({ visible: false });
3778
+ p90Series.applyOptions({ visible: false });
3779
+ buildForecastSegmentSeries(p50);
3780
+
3781
+ const anchorVal = anchorPoint.value;
3782
  lastForecastVal = forecastPoints[forecastPoints.length - 1]?.p50 ?? anchorVal;
3783
  isBull = lastForecastVal >= anchorVal;
3784
 
 
 
 
 
 
 
3785
  }
3786
 
3787
  const currentPrice = lastCandleData?.close || 0;
 
4023
  </script>
4024
  </body>
4025
 
4026
+ </html>