diff --git "a/backend/main.py" "b/backend/main.py" --- "a/backend/main.py" +++ "b/backend/main.py" @@ -58,11 +58,17 @@ import asyncio import logging import math import os +import hmac +import hashlib +from dotenv import load_dotenv + +# D-1: Load environment variables from .env file (v6.0) +load_dotenv() import re import sys import time from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple @@ -83,16 +89,22 @@ from pydantic import BaseModel, ConfigDict # Architecture v5: Configuration Management (C-1) # ────────────────────────────────────────────────────────────────────────────── class Settings(BaseModel): - # API Keys (Hardcoded as requested) - twelvedata_api_key: str = "ede46395568d4bdf8195970d19b72880" - finnhub_api_key: str = "d7ebfk1r01qu8k17ui80d7ebfk1r01qu8k17ui8g" - alpha_vantage_key: str = "" + # D-1: Security Hardening (v6.0) - Enforce environment variables + twelvedata_api_key: Optional[str] = os.getenv("TWELVEDATA_API_KEY") + finnhub_api_key: Optional[str] = os.getenv("FINNHUB_API_KEY") + binance_api_key: Optional[str] = os.getenv("BINANCE_API_KEY") + binance_api_secret: Optional[str] = os.getenv("BINANCE_API_SECRET") + bybit_api_key: Optional[str] = os.getenv("BYBIT_API_KEY") + bybit_api_secret: Optional[str] = os.getenv("BYBIT_API_SECRET") + gemini_api_key: Optional[str] = os.getenv("GEMINI_API_KEY") + alphavantage_api_key: Optional[str] = os.getenv("ALPHAVANTAGE_API_KEY") + admin_token: str = os.getenv("ADMIN_TOKEN", "kronos_v6_default_secret") # App Config - host: str = "0.0.0.0" - port: int = 8000 - preload_kronos: bool = True - cache_version: str = "v11" + host: str = os.getenv("HOST", "0.0.0.0") + port: int = int(os.getenv("PORT", 8000)) + preload_kronos: bool = os.getenv("PRELOAD_KRONOS", "True").lower() == "true" + cache_version: str = "v6.0.0-final" # WebSocket Config ws_heartbeat_interval: int = 20 @@ -246,6 +258,7 @@ class PersistentCache: conn.execute("CREATE INDEX IF NOT EXISTS idx_expiry ON cache(expiry)") def get(self, key: str) -> Optional[Any]: + """Synchronous read is usually fast enough for SQLite.""" try: with sqlite3.connect(self.db_path) as conn: cur = conn.execute("SELECT payload, expiry FROM cache WHERE key = ?", (key,)) @@ -261,6 +274,14 @@ class PersistentCache: return None def set(self, key: str, payload: Any, ttl: int): + """Asynchronous write: push to queue (P0).""" + if hasattr(self, "_queue"): + self._queue.put_nowait((key, payload, ttl)) + else: + # Fallback if queue not ready + self._write_sync(key, payload, ttl) + + def _write_sync(self, key: str, payload: Any, ttl: int): try: with sqlite3.connect(self.db_path) as conn: conn.execute( @@ -268,24 +289,38 @@ class PersistentCache: (key, json.dumps(payload), time.time() + ttl, settings.cache_version) ) except Exception as ex: - logger.error("[Persistence] Write error: %s", ex) + logger.error("[Persistence] Sync write error: %s", ex) + + async def start_writer(self): + """Worker loop to process async writes.""" + self._queue = asyncio.Queue() + logger.info("[Persistence] Async writer started.") + while True: + key, payload, ttl = await self._queue.get() + try: + await asyncio.to_thread(self._write_sync, key, payload, ttl) + except Exception as ex: + logger.error("[Persistence] Writer loop error: %s", ex) + finally: + self._queue.task_done() def evict(self): - with sqlite3.connect(self.db_path) as conn: - conn.execute("DELETE FROM cache WHERE expiry < ?", (time.time(),)) + try: + with sqlite3.connect(self.db_path) as conn: + conn.execute("DELETE FROM cache WHERE expiry < ?", (time.time(),)) + except Exception as ex: + logger.error("[Persistence] Eviction error: %s", ex) persistent_cache = PersistentCache(os.path.join(PROJECT_ROOT, "kronos_v5.db")) -# ────────────────────────────────────────────────────────────────────────────── -# API Keys (Migrated to Settings) -# ────────────────────────────────────────────────────────────────────────────── -TWELVEDATA_API_KEY = settings.twelvedata_api_key -FINNHUB_API_KEY = settings.finnhub_api_key -ALPHA_VANTAGE_KEY = settings.alpha_vantage_key -if ALPHA_VANTAGE_KEY == "demo" or not ALPHA_VANTAGE_KEY: - logger.warning("[AlphaVantage] No valid key - restricting to sample data only") -# Binance, Bybit, CoinGecko, yfinance, FRED — no key required +# B-12: Global Configuration Instances +settings = Settings() +CACHE_VERSION = settings.cache_version +ADMIN_TOKEN = settings.admin_token +TWELVEDATA_API_KEY = settings.twelvedata_api_key +FINNHUB_API_KEY = settings.finnhub_api_key +# Binance, Bybit, CoinGecko, yfinance, FRED — no key or optional key required # ────────────────────────────────────────────────────────────────────────────── # Constants @@ -325,21 +360,21 @@ STEP_SECONDS: Dict[str, int] = { # Source priority by asset category (first available mapping wins) CATEGORY_SOURCE_PRIORITY: Dict[str, List[str]] = { "Crypto": ["binance", "bybit", "coingecko", "yfinance", "finnhub"], - "Cặp tiền": ["twelvedata", "finnhub", "yfinance"], - "Kim loại": ["yfinance", "twelvedata", "finnhub"], - "Năng lượng": ["yfinance", "twelvedata", "finnhub"], - "Nông sản": ["yfinance", "twelvedata"], - "Nguyên liệu CN": ["yfinance", "twelvedata"], - "Chỉ số": ["yfinance", "twelvedata", "finnhub"], - "Cổ phiếu Mỹ": ["yfinance", "finnhub", "twelvedata"], + "Cặp tiền": ["binance", "twelvedata", "finnhub", "yfinance"], + "Kim loại": ["binance", "twelvedata", "yfinance", "finnhub"], + "Năng lượng": ["twelvedata", "yfinance", "finnhub"], + "Nông sản": ["twelvedata", "yfinance"], + "Nguyên liệu CN": ["twelvedata", "yfinance"], + "Chỉ số": ["binance", "twelvedata", "yfinance", "finnhub"], + "Cổ phiếu Mỹ": ["binance", "twelvedata", "finnhub", "yfinance"], "Cổ phiếu VN": ["yfinance"], - "Trái phiếu": ["yfinance"], - "ETF": ["yfinance", "finnhub"], + "Trái phiếu": ["twelvedata", "yfinance"], + "ETF": ["binance", "twelvedata", "yfinance", "finnhub"], } # Fallback for unknown categories -DEFAULT_SOURCE_PRIORITY: List[str] = ["yfinance", "twelvedata", "finnhub", "binance"] +DEFAULT_SOURCE_PRIORITY: List[str] = ["binance", "yfinance", "twelvedata", "finnhub"] -CACHE_VERSION = "v11" +CACHE_VERSION = "v12" CLIP_DEFAULT = 3.0 @@ -369,7 +404,7 @@ class TokenBucket: # Per-source buckets (conservative — stays well within free limits) _rate_limiters: Dict[str, TokenBucket] = { "binance": TokenBucket(rate=10.0, capacity=20), - "bybit": TokenBucket(rate=2.0, capacity=5), + "bybit": TokenBucket(rate=1.5, capacity=5), "coingecko": TokenBucket(rate=0.4, capacity=3), "twelvedata": TokenBucket(rate=0.1, capacity=2), # 8/min free = ~0.13/s "finnhub": TokenBucket(rate=1.0, capacity=5), @@ -408,357 +443,195 @@ class SymbolConfig: mappings: Dict[str, str] coingecko_id: Optional[str] = None # for CoinGecko OHLCV bybit_category: str = "linear" # "linear" or "spot" + binance_type: str = "spot" # "spot" or "futures" description: str = "" # ─── Helper to build entry quickly ──────────────────────────────────────────── def _s(sym: str, label: str, label_en: str, cat: str, - mappings: Dict[str, str], cg_id: str = None, desc: str = "", bybit_cat: str = "linear") -> SymbolConfig: - return SymbolConfig(sym, label, label_en, cat, mappings, cg_id, bybit_cat, desc) + mappings: Dict[str, str], cg_id: str = None, desc: str = "", + bybit_cat: str = "linear", bin_type: str = "spot") -> SymbolConfig: + return SymbolConfig(sym, label, label_en, cat, mappings, cg_id, bybit_cat, bin_type, desc) SYMBOLS: Dict[str, SymbolConfig] = { # ══════════════════════════════════════════════════════════════════════ - # 1. KIM LOẠI QUÝ & CÔNG NGHIỆP (Metals) + # 1. KIM LOẠI (Metals) # ══════════════════════════════════════════════════════════════════════ - "XAUUSD": _s("XAUUSD","Vàng (XAU/USD)","Gold","Kim loại", - {"twelvedata":"XAU/USD","finnhub":"OANDA:XAU_USD","yfinance":"GC=F"}, - desc="Vàng giao ngay – chuẩn mực trú ẩn an toàn toàn cầu"), - "XAGUSD": _s("XAGUSD","Bạc (XAG/USD)","Silver","Kim loại", - {"twelvedata":"XAG/USD","finnhub":"OANDA:XAG_USD","yfinance":"SI=F"}), - "XPTUSD": _s("XPTUSD","Bạch kim (XPT/USD)","Platinum","Kim loại", - {"twelvedata":"XPT/USD","yfinance":"PL=F"}), - "XPDUSD": _s("XPDUSD","Palladium (XPD/USD)","Palladium","Kim loại", - {"twelvedata":"XPD/USD","yfinance":"PA=F"}), - "COPPER": _s("COPPER","Đồng COMEX (HG)","Copper COMEX","Kim loại", - {"yfinance":"HG=F","twelvedata":"HG1"}), - "COPPER_LME": _s("COPPER_LME","Đồng LME","Copper LME","Kim loại", - {"twelvedata":"XCU/USD","yfinance":"HG=F"}), - "ALUMINUM": _s("ALUMINUM","Nhôm (ALI)","Aluminum","Kim loại", - {"yfinance":"ALI=F"}), - "ALUMINUM_LME": _s("ALUMINUM_LME","Nhôm LME","Aluminum LME","Kim loại", - {"twelvedata":"LMAHDS03"}), - "NICKEL_LME": _s("NICKEL_LME","Niken LME","Nickel LME","Kim loại", - {"twelvedata":"NI1"}), - "ZINC_LME": _s("ZINC_LME","Kẽm LME","Zinc LME","Kim loại", - {"twelvedata":"ZN1"}), - "IRON_ORE": _s("IRON_ORE","Quặng sắt (SGX)","Iron Ore","Kim loại", - {"yfinance":"TIO=F"}), - "STEEL_HRC": _s("STEEL_HRC","Thép cuộn cán nóng","Steel HRC","Kim loại", - {"yfinance":"HRC=F"}), - "LITHIUM": _s("LITHIUM","Lithium Futures","Lithium","Kim loại", - {"yfinance":"ALB"}), # ALB proxy for lithium + "XAUUSD": _s("XAUUSD","Vàng (Gold)","Gold","Kim loại",{"binance":"PAXGUSDT","twelvedata":"XAU/USD","yfinance":"GC=F"}), + "XAGUSD": _s("XAGUSD","Bạc (Silver)","Silver","Kim loại",{"twelvedata":"XAG/USD","yfinance":"SI=F"}), + "XPTUSD": _s("XPTUSD","Bạch kim (Platinum)","Platinum","Kim loại",{"twelvedata":"XPT/USD","yfinance":"PL=F"}), + "XPDUSD": _s("XPDUSD","Palladium","Palladium","Kim loại",{"twelvedata":"XPD/USD","yfinance":"PA=F"}), + "HGUSD": _s("HGUSD","Đồng (Copper)","Copper","Kim loại",{"twelvedata":"COPPER","yfinance":"HG=F"}), + "ALUSD": _s("ALUSD","Nhôm (Aluminum)","Aluminum","Kim loại",{"yfinance":"AL=F"}), + "ZNUSD": _s("ZNUSD","Kẽm (Zinc)","Zinc","Kim loại",{"yfinance":"ZN=F"}), + "NIUSD": _s("NIUSD","Niken (Nickel)","Nickel","Kim loại",{"yfinance":"NI=F"}), + "PBUSD": _s("PBUSD","Chì (Lead)","Lead","Kim loại",{"yfinance":"LED=F"}), + "SNUSD": _s("SNUSD","Thiếc (Tin)","Tin","Kim loại",{"yfinance":"SN=F"}), # ══════════════════════════════════════════════════════════════════════ - # 2. NĂNG LƯỢNG (Energy) + # 2. NĂNG LƯỢNG (Energy) # ══════════════════════════════════════════════════════════════════════ - "WTI": _s("WTI","Dầu thô WTI (CL)","WTI Crude Oil","Năng lượng", - {"yfinance":"CL=F","twelvedata":"CL=F","finnhub":"NYMEX:CL"}, - desc="Dầu thô West Texas Intermediate – chuẩn Mỹ"), - "BRENT": _s("BRENT","Dầu Brent (BZ)","Brent Crude","Năng lượng", - {"yfinance":"BZ=F","twelvedata":"CB1"}, - desc="Dầu thô Brent – chuẩn quốc tế"), - "NATURAL_GAS": _s("NATURAL_GAS","Khí tự nhiên (NG)","Natural Gas","Năng lượng", - {"yfinance":"NG=F","twelvedata":"NG=F"}), - "GASOLINE_RBOB": _s("GASOLINE_RBOB","Xăng RBOB","Gasoline RBOB","Năng lượng", - {"yfinance":"RB=F"}), - "HEATING_OIL": _s("HEATING_OIL","Dầu sưởi (HO)","Heating Oil","Năng lượng", - {"yfinance":"HO=F"}), - "LNG": _s("LNG","Khí tự nhiên hóa lỏng","LNG","Năng lượng", - {"yfinance":"LNG"}), # Cheniere proxy - "COAL": _s("COAL","Than Newcastle (XAB)","Coal Newcastle","Năng lượng", - {"yfinance":"MTF=F"}), - "URANIUM": _s("URANIUM","Uranium (UX)","Uranium","Năng lượng", - {"yfinance":"UX=F"}), + "USOIL": _s("USOIL","Dầu thô WTI","WTI Oil","Năng lượng",{"twelvedata":"WTI","yfinance":"CL=F"}), + "UKOIL": _s("UKOIL","Dầu Brent","Brent Oil","Năng lượng",{"twelvedata":"BRENT","yfinance":"BZ=F"}), + "NGAS": _s("NGAS","Khí tự nhiên","Natural Gas","Năng lượng",{"twelvedata":"NATGAS","yfinance":"NG=F"}), + "GASO": _s("GASO","Xăng RBOB","Gasoline","Năng lượng",{"yfinance":"RB=F"}), + "HEAT": _s("HEAT","Dầu sưởi","Heating Oil","Năng lượng",{"yfinance":"HO=F"}), + "COAL": _s("COAL","Than (Coal)","Coal","Năng lượng",{"yfinance":"MTF=F"}), # ══════════════════════════════════════════════════════════════════════ - # 3. NÔNG SẢN (Agricultural) + # 3. NGUYÊN LIỆU CÔNG NGHIỆP (Industrial Materials) # ══════════════════════════════════════════════════════════════════════ - "CORN": _s("CORN","Ngô (ZC)","Corn","Nông sản", - {"yfinance":"ZC=F","twelvedata":"ZC=F"}), - "WHEAT": _s("WHEAT","Lúa mì (ZW)","Wheat","Nông sản", - {"yfinance":"ZW=F","twelvedata":"ZW=F"}), - "SOYBEAN": _s("SOYBEAN","Đậu tương (ZS)","Soybean","Nông sản", - {"yfinance":"ZS=F","twelvedata":"ZS=F"}), - "SOYBEAN_OIL": _s("SOYBEAN_OIL","Dầu đậu tương (ZL)","Soybean Oil","Nông sản", - {"yfinance":"ZL=F","twelvedata":"ZL=F"}), - "SOYBEAN_MEAL": _s("SOYBEAN_MEAL","Khô đậu tương (ZM)","Soybean Meal","Nông sản", - {"yfinance":"ZM=F","twelvedata":"ZM=F"}), - "RICE": _s("RICE","Gạo thô (ZR)","Rough Rice","Nông sản", - {"yfinance":"ZR=F"}), - "OATS": _s("OATS","Yến mạch (ZO)","Oats","Nông sản", - {"yfinance":"ZO=F"}), + "COFFEE": _s("COFFEE","Cà phê Arabica","Arabica Coffee","Nguyên liệu CN",{"twelvedata":"COFFEE","yfinance":"KC=F"}), + "ROBUSTA":_s("ROBUSTA","Cà phê Robusta","Robusta Coffee","Nguyên liệu CN",{"yfinance":"RC=F"}), + "COCOA": _s("COCOA","Ca cao (Cocoa)","Cocoa","Nguyên liệu CN",{"yfinance":"CC=F"}), + "SUGAR11":_s("SUGAR11","Đường 11","Sugar No.11","Nguyên liệu CN",{"twelvedata":"SUGAR","yfinance":"SB=F"}), + "LSUGAR": _s("LSUGAR","Đường trắng","White Sugar","Nguyên liệu CN",{"yfinance":"LSU.L"}), + "PALMOIL":_s("PALMOIL","Dầu cọ thô","Palm Oil","Nguyên liệu CN",{"yfinance":"FCPO=F"}), + "COTTON": _s("COTTON","Bông (Cotton)","Cotton","Nguyên liệu CN",{"twelvedata":"COTTON","yfinance":"CT=F"}), + "RUBBER": _s("RUBBER","Cao su RSS3","Rubber","Nguyên liệu CN",{"yfinance":"JR=F"}), + "ORANGE": _s("ORANGE","Nước cam","Orange Juice","Nguyên liệu CN",{"yfinance":"OJ=F"}), # ══════════════════════════════════════════════════════════════════════ - # 4. NGUYÊN LIỆU CÔNG NGHIỆP (Soft Commodities) + # 4. NÔNG SẢN (Agriculture) # ══════════════════════════════════════════════════════════════════════ - "COFFEE_ARABICA": _s("COFFEE_ARABICA","Cà phê Arabica (KC)","Coffee Arabica","Nguyên liệu CN", - {"yfinance":"KC=F"}), - "COFFEE_ROBUSTA": _s("COFFEE_ROBUSTA","Cà phê Robusta (RC)","Coffee Robusta","Nguyên liệu CN", - {"yfinance":"RC=F"}), - "COCOA": _s("COCOA","Ca cao (CC)","Cocoa","Nguyên liệu CN", - {"yfinance":"CC=F"}), - "SUGAR_11": _s("SUGAR_11","Đường 11 (SB)","Sugar No.11","Nguyên liệu CN", - {"yfinance":"SB=F"}), - "WHITE_SUGAR": _s("WHITE_SUGAR","Đường trắng (LSW)","White Sugar","Nguyên liệu CN", - {"yfinance":"LSW=F"}), - "COTTON": _s("COTTON","Bông (CT)","Cotton","Nguyên liệu CN", - {"yfinance":"CT=F"}), - "ORANGE_JUICE": _s("ORANGE_JUICE","Nước cam (OJ)","Orange Juice","Nguyên liệu CN", - {"yfinance":"OJ=F"}), - "LUMBER": _s("LUMBER","Gỗ xẻ (LBS)","Lumber","Nguyên liệu CN", - {"yfinance":"LBS=F"}), - "RUBBER_RSS3": _s("RUBBER_RSS3","Cao su RSS3","Rubber RSS3","Nguyên liệu CN", - {"yfinance":"JRU=F"}), - "PALM_OIL": _s("PALM_OIL","Dầu cọ thô (FCPO)","Palm Oil","Nguyên liệu CN", - {"yfinance":"FCPO=F"}), + "CORN": _s("CORN","Ngô (Corn)","Corn","Nông sản",{"twelvedata":"CORN","yfinance":"ZC=F"}), + "WHEAT": _s("WHEAT","Lúa mì (Wheat)","Wheat","Nông sản",{"twelvedata":"WHEAT","yfinance":"ZW=F"}), + "SOY": _s("SOY","Đậu tương","Soybeans","Nông sản",{"twelvedata":"SOYBEAN","yfinance":"ZS=F"}), + "SOYOIL": _s("SOYOIL","Dầu đậu tương","Soybean Oil","Nông sản",{"yfinance":"ZL=F"}), + "SOYMEAL":_s("SOYMEAL","Khô đậu tương","Soybean Meal","Nông sản",{"yfinance":"ZM=F"}), + "RICE": _s("RICE","Gạo (Rice)","Rice","Nông sản",{"yfinance":"ZR=F"}), + "OATS": _s("OATS","Yến mạch (Oats)","Oats","Nông sản",{"yfinance":"ZO=F"}), # ══════════════════════════════════════════════════════════════════════ - # 5. CRYPTO — Top 30 by market cap (nguồn: Binance + Bybit + CoinGecko) + # 5. CRYPTO (Top 50+) # ══════════════════════════════════════════════════════════════════════ - "BTCUSD": _s("BTCUSD","Bitcoin (BTC)","Bitcoin","Crypto", - {"binance":"BTCUSDT","bybit":"BTCUSDT","coingecko":"bitcoin", - "finnhub":"BINANCE:BTCUSDT","twelvedata":"BTC/USD","yfinance":"BTC-USD"}, - cg_id="bitcoin", desc="Đồng tiền mã hóa đầu tiên và lớn nhất thế giới"), - "ETHUSD": _s("ETHUSD","Ethereum (ETH)","Ethereum","Crypto", - {"binance":"ETHUSDT","bybit":"ETHUSDT","coingecko":"ethereum", - "finnhub":"BINANCE:ETHUSDT","twelvedata":"ETH/USD","yfinance":"ETH-USD"}, - cg_id="ethereum"), - "BNBUSD": _s("BNBUSD","BNB (BNB)","BNB","Crypto", - {"binance":"BNBUSDT","bybit":"BNBUSDT","coingecko":"binancecoin", - "finnhub":"BINANCE:BNBUSDT","yfinance":"BNB-USD"}, - cg_id="binancecoin"), - "SOLUSD": _s("SOLUSD","Solana (SOL)","Solana","Crypto", - {"binance":"SOLUSDT","bybit":"SOLUSDT","coingecko":"solana", - "finnhub":"BINANCE:SOLUSDT","yfinance":"SOL-USD"}, - cg_id="solana"), - "XRPUSD": _s("XRPUSD","Ripple (XRP)","XRP","Crypto", - {"binance":"XRPUSDT","bybit":"XRPUSDT","coingecko":"ripple", - "finnhub":"BINANCE:XRPUSDT","yfinance":"XRP-USD"}, - cg_id="ripple"), - "ADAUSD": _s("ADAUSD","Cardano (ADA)","Cardano","Crypto", - {"binance":"ADAUSDT","bybit":"ADAUSDT","coingecko":"cardano","yfinance":"ADA-USD"}, - cg_id="cardano"), - "AVAXUSD": _s("AVAXUSD","Avalanche (AVAX)","Avalanche","Crypto", - {"binance":"AVAXUSDT","bybit":"AVAXUSDT","coingecko":"avalanche-2","yfinance":"AVAX-USD"}, - cg_id="avalanche-2"), - "DOTUSD": _s("DOTUSD","Polkadot (DOT)","Polkadot","Crypto", - {"binance":"DOTUSDT","bybit":"DOTUSDT","coingecko":"polkadot","yfinance":"DOT-USD"}, - cg_id="polkadot"), - "MATICUSD": _s("MATICUSD","Polygon (MATIC/POL)","Polygon","Crypto", - {"binance":"MATICUSDT","bybit":"MATICUSDT","coingecko":"matic-network","yfinance":"MATIC-USD"}, - cg_id="matic-network"), - "LINKUSD": _s("LINKUSD","Chainlink (LINK)","Chainlink","Crypto", - {"binance":"LINKUSDT","bybit":"LINKUSDT","coingecko":"chainlink","yfinance":"LINK-USD"}, - cg_id="chainlink"), - "LTCUSD": _s("LTCUSD","Litecoin (LTC)","Litecoin","Crypto", - {"binance":"LTCUSDT","bybit":"LTCUSDT","coingecko":"litecoin","yfinance":"LTC-USD"}, - cg_id="litecoin"), - "UNIUSD": _s("UNIUSD","Uniswap (UNI)","Uniswap","Crypto", - {"binance":"UNIUSDT","bybit":"UNIUSDT","coingecko":"uniswap","yfinance":"UNI-USD"}, - cg_id="uniswap"), - "ATOMUSD": _s("ATOMUSD","Cosmos (ATOM)","Cosmos","Crypto", - {"binance":"ATOMUSDT","bybit":"ATOMUSDT","coingecko":"cosmos","yfinance":"ATOM-USD"}, - cg_id="cosmos"), - "NEARUSD": _s("NEARUSD","NEAR Protocol","NEAR","Crypto", - {"binance":"NEARUSDT","bybit":"NEARUSDT","coingecko":"near","yfinance":"NEAR-USD"}, - cg_id="near"), - "APTUSD": _s("APTUSD","Aptos (APT)","Aptos","Crypto", - {"binance":"APTUSDT","bybit":"APTUSDT","coingecko":"aptos","yfinance":"APT-USD"}, - cg_id="aptos"), - "SUIUSD": _s("SUIUSD","Sui (SUI)","Sui","Crypto", - {"binance":"SUIUSDT","bybit":"SUIUSDT","coingecko":"sui","yfinance":"SUI20947-USD"}, - cg_id="sui"), - "ARBUSD": _s("ARBUSD","Arbitrum (ARB)","Arbitrum","Crypto", - {"binance":"ARBUSDT","bybit":"ARBUSDT","coingecko":"arbitrum","yfinance":"ARB-USD"}, - cg_id="arbitrum"), - "OPUSD": _s("OPUSD","Optimism (OP)","Optimism","Crypto", - {"binance":"OPUSDT","bybit":"OPUSDT","coingecko":"optimism","yfinance":"OP-USD"}, - cg_id="optimism"), - "INJUSD": _s("INJUSD","Injective (INJ)","Injective","Crypto", - {"binance":"INJUSDT","bybit":"INJUSDT","coingecko":"injective-protocol"}, - cg_id="injective-protocol"), - "TONUSD": _s("TONUSD","Toncoin (TON)","Toncoin","Crypto", - {"binance":"TONUSDT","bybit":"TONUSDT","coingecko":"the-open-network"}, - cg_id="the-open-network"), - "TRXUSD": _s("TRXUSD","Tron (TRX)","Tron","Crypto", - {"binance":"TRXUSDT","bybit":"TRXUSDT","coingecko":"tron","yfinance":"TRX-USD"}, - cg_id="tron"), - "XMRUSD": _s("XMRUSD","Monero (XMR)","Monero","Crypto", - {"bybit":"XMRUSDT","coingecko":"monero","yfinance":"XMR-USD"}, - cg_id="monero"), - "DOGEUSD": _s("DOGEUSD","Dogecoin (DOGE)","Dogecoin","Crypto", - {"binance":"DOGEUSDT","bybit":"DOGEUSDT","coingecko":"dogecoin","yfinance":"DOGE-USD"}, - cg_id="dogecoin"), - "SHIBAUSD": _s("SHIBAUSD","Shiba Inu (SHIB)","Shiba Inu","Crypto", - {"binance":"SHIBUSDT","coingecko":"shiba-inu","yfinance":"SHIB-USD"}, - cg_id="shiba-inu"), - "PEPE": _s("PEPE","PEPE Coin","PEPE","Crypto", - {"binance":"PEPEUSDT","coingecko":"pepe","yfinance":"PEPE24478-USD"}, - cg_id="pepe"), + "BTCUSD": _s("BTCUSD","Bitcoin (BTC)","Bitcoin","Crypto",{"binance":"BTCUSDT","bybit":"BTCUSDT","coingecko":"bitcoin"}), + "ETHUSD": _s("ETHUSD","Ethereum (ETH)","Ethereum","Crypto",{"binance":"ETHUSDT","bybit":"ETHUSDT","coingecko":"ethereum"}), + "BNBUSD": _s("BNBUSD","BNB (BNB)","BNB","Crypto",{"binance":"BNBUSDT","bybit":"BNBUSDT","coingecko":"binancecoin"}), + "SOLUSD": _s("SOLUSD","Solana (SOL)","Solana","Crypto",{"binance":"SOLUSDT","bybit":"SOLUSDT","coingecko":"solana"}), + "XRPUSD": _s("XRPUSD","Ripple (XRP)","XRP","Crypto",{"binance":"XRPUSDT","bybit":"XRPUSDT","coingecko":"ripple"}), + "ADAUSD": _s("ADAUSD","Cardano (ADA)","Cardano","Crypto",{"binance":"ADAUSDT","bybit":"ADAUSDT","coingecko":"cardano"}), + "AVAXUSD": _s("AVAXUSD","Avalanche (AVAX)","Avalanche","Crypto",{"binance":"AVAXUSDT","bybit":"AVAXUSDT","coingecko":"avalanche-2"}), + "DOTUSD": _s("DOTUSD","Polkadot (DOT)","Polkadot","Crypto",{"binance":"DOTUSDT","bybit":"DOTUSDT","coingecko":"polkadot"}), + "MATICUSD": _s("MATICUSD","Polygon (MATIC)","Polygon","Crypto",{"binance":"MATICUSDT","bybit":"MATICUSDT","coingecko":"matic-network"}), + "LINKUSD": _s("LINKUSD","Chainlink (LINK)","Chainlink","Crypto",{"binance":"LINKUSDT","bybit":"LINKUSDT","coingecko":"chainlink"}), + "LTCUSD": _s("LTCUSD","Litecoin (LTC)","Litecoin","Crypto",{"binance":"LTCUSDT","bybit":"LTCUSDT","coingecko":"litecoin"}), + "UNIUSD": _s("UNIUSD","Uniswap (UNI)","Uniswap","Crypto",{"binance":"UNIUSDT","bybit":"UNIUSDT","coingecko":"uniswap"}), + "ATOMUSD": _s("ATOMUSD","Cosmos (ATOM)","Cosmos","Crypto",{"binance":"ATOMUSDT"}), + "NEARUSD": _s("NEARUSD","NEAR Protocol","NEAR","Crypto",{"binance":"NEARUSDT"}), + "APTUSD": _s("APTUSD","Aptos (APT)","Aptos","Crypto",{"binance":"APTUSDT"}), + "SUIUSD": _s("SUIUSD","Sui (SUI)","Sui","Crypto",{"binance":"SUIUSDT"}), + "ARBUSD": _s("ARBUSD","Arbitrum (ARB)","Arbitrum","Crypto",{"binance":"ARBUSDT"}), + "OPUSD": _s("OPUSD","Optimism (OP)","Optimism","Crypto",{"binance":"OPUSDT"}), + "TONUSD": _s("TONUSD","Toncoin (TON)","Toncoin","Crypto",{"binance":"TONUSDT"}), + "TRXUSD": _s("TRXUSD","Tron (TRX)","Tron","Crypto",{"binance":"TRXUSDT"}), + "DOGEUSD":_s("DOGEUSD","Dogecoin (DOGE)","Dogecoin","Crypto",{"binance":"DOGEUSDT"}), + "SHIBUSD":_s("SHIBUSD","Shiba Inu (SHIB)","Shiba Inu","Crypto",{"binance":"SHIBUSDT"}), + "PEPEUSD":_s("PEPEUSD","PEPE Coin","PEPE","Crypto",{"binance":"PEPEUSDT"}), + "FLOKIUSD":_s("FLOKIUSD","FLOKI Inu","FLOKI","Crypto",{"binance":"FLOKIUSDT"}), + "BONKUSD":_s("BONKUSD","BONK Coin","BONK","Crypto",{"binance":"BONKUSDT"}), + "WIFUSD": _s("WIFUSD","dogwifhat (WIF)","WIF","Crypto",{"binance":"WIFUSDT"}), + "RNDRUSD":_s("RNDRUSD","Render (RNDR)","Render","Crypto",{"binance":"RNDRUSDT"}), + "FETUSD": _s("FETUSD","Fetch.ai (FET)","Fetch.ai","Crypto",{"binance":"FETUSDT"}), + "STXUSD": _s("STXUSD","Stacks (STX)","Stacks","Crypto",{"binance":"STXUSDT"}), + "FILUSD": _s("FILUSD","Filecoin (FIL)","Filecoin","Crypto",{"binance":"FILUSDT"}), + "VETUSD": _s("VETUSD","VeChain (VET)","VeChain","Crypto",{"binance":"VETUSDT"}), + "ICPUSD": _s("ICPUSD","Internet Computer","ICP","Crypto",{"binance":"ICPUSDT"}), # ══════════════════════════════════════════════════════════════════════ - # 6. CẶP TIỀN (Forex) + # 6. CẶP TIỀN (Forex) # ══════════════════════════════════════════════════════════════════════ - "DXY": _s("DXY","Chỉ số USD (DXY)","USD Index","Cặp tiền", - {"yfinance":"DX-Y.NYB"}, - desc="Rổ 6 đồng tiền chính so với USD"), - "EURUSD": _s("EURUSD","EUR/USD","EUR/USD","Cặp tiền", - {"twelvedata":"EUR/USD","finnhub":"OANDA:EUR_USD","yfinance":"EURUSD=X"}), - "GBPUSD": _s("GBPUSD","GBP/USD","GBP/USD","Cặp tiền", - {"twelvedata":"GBP/USD","finnhub":"OANDA:GBP_USD","yfinance":"GBPUSD=X"}), - "USDJPY": _s("USDJPY","USD/JPY","USD/JPY","Cặp tiền", - {"twelvedata":"USD/JPY","finnhub":"OANDA:USD_JPY","yfinance":"JPY=X"}), - "USDCHF": _s("USDCHF","USD/CHF","USD/CHF","Cặp tiền", - {"twelvedata":"USD/CHF","finnhub":"OANDA:USD_CHF","yfinance":"CHF=X"}), - "AUDUSD": _s("AUDUSD","AUD/USD","AUD/USD","Cặp tiền", - {"twelvedata":"AUD/USD","finnhub":"OANDA:AUD_USD","yfinance":"AUDUSD=X"}), - "USDCAD": _s("USDCAD","USD/CAD","USD/CAD","Cặp tiền", - {"twelvedata":"USD/CAD","finnhub":"OANDA:USD_CAD","yfinance":"CAD=X"}), - "NZDUSD": _s("NZDUSD","NZD/USD","NZD/USD","Cặp tiền", - {"twelvedata":"NZD/USD","finnhub":"OANDA:NZD_USD","yfinance":"NZDUSD=X"}), - "GBPJPY": _s("GBPJPY","GBP/JPY","GBP/JPY","Cặp tiền", - {"twelvedata":"GBP/JPY","finnhub":"OANDA:GBP_JPY","yfinance":"GBPJPY=X"}), - "EURJPY": _s("EURJPY","EUR/JPY","EUR/JPY","Cặp tiền", - {"twelvedata":"EUR/JPY","finnhub":"OANDA:EUR_JPY","yfinance":"EURJPY=X"}), - "EURGBP": _s("EURGBP","EUR/GBP","EUR/GBP","Cặp tiền", - {"twelvedata":"EUR/GBP","finnhub":"OANDA:EUR_GBP","yfinance":"EURGBP=X"}), - "CADCHF": _s("CADCHF","CAD/CHF","CAD/CHF","Cặp tiền", - {"twelvedata":"CAD/CHF","finnhub":"OANDA:CAD_CHF","yfinance":"CADCHF=X"}), - "AUDNZD": _s("AUDNZD","AUD/NZD","AUD/NZD","Cặp tiền", - {"twelvedata":"AUD/NZD","finnhub":"OANDA:AUD_NZD","yfinance":"AUDNZD=X"}), - "USDVND": _s("USDVND","USD/VND","USD/VND","Cặp tiền", - {"yfinance":"VND=X"}, - desc="Tỷ giá đô la Mỹ – đồng Việt Nam"), - "USDHKD": _s("USDHKD","USD/HKD","USD/HKD","Cặp tiền", - {"twelvedata":"USD/HKD","yfinance":"HKD=X"}), - "USDSGD": _s("USDSGD","USD/SGD","USD/SGD","Cặp tiền", - {"twelvedata":"USD/SGD","yfinance":"SGD=X"}), - "USDCNY": _s("USDCNY","USD/CNY (Offshore)","USD/CNH","Cặp tiền", - {"twelvedata":"USD/CNH","yfinance":"CNY=X"}), - "USDINR": _s("USDINR","USD/INR","USD/INR","Cặp tiền", - {"yfinance":"INR=X"}), - "USDBRL": _s("USDBRL","USD/BRL","USD/BRL","Cặp tiền", - {"yfinance":"BRL=X"}), + "DXY": _s("DXY","Chỉ số USD (DXY)","USD Index","Cặp tiền",{"twelvedata":"DXY","yfinance":"DX-Y.NYB"}), + "EURUSD": _s("EURUSD","EUR/USD","EUR/USD","Cặp tiền",{"binance":"EURUSDT","twelvedata":"EUR/USD","yfinance":"EURUSD=X"}), + "GBPUSD": _s("GBPUSD","GBP/USD","GBP/USD","Cặp tiền",{"binance":"GBPUSDT","twelvedata":"GBP/USD","yfinance":"GBPUSD=X"}), + "USDJPY": _s("USDJPY","USD/JPY","USD/JPY","Cặp tiền",{"binance":"JPYUSDT","twelvedata":"USD/JPY","yfinance":"JPY=X"}), + "USDCHF": _s("USDCHF","USD/CHF","USD/CHF","Cặp tiền",{"twelvedata":"USD/CHF","yfinance":"CHF=X"}), + "AUDUSD": _s("AUDUSD","AUD/USD","AUD/USD","Cặp tiền",{"binance":"AUDUSDT","twelvedata":"AUD/USD","yfinance":"AUDUSD=X"}), + "USDCAD": _s("USDCAD","USD/CAD","USD/CAD","Cặp tiền",{"twelvedata":"USD/CAD","yfinance":"CAD=X"}), + "NZDUSD": _s("NZDUSD","NZD/USD","NZD/USD","Cặp tiền",{"twelvedata":"NZD/USD","yfinance":"NZDUSD=X"}), + "GBPJPY": _s("GBPJPY","GBP/JPY","GBP/JPY","Cặp tiền",{"twelvedata":"GBP/JPY","yfinance":"GBPJPY=X"}), + "EURJPY": _s("EURJPY","EUR/JPY","EUR/JPY","Cặp tiền",{"binance":"EURJPY","twelvedata":"EUR/JPY"}), + "EURGBP": _s("EURGBP","EUR/GBP","EUR/GBP","Cặp tiền",{"binance":"EURGBP","twelvedata":"EUR/GBP"}), + "USDVND": _s("USDVND","USD/VND","USD/VND","Cặp tiền",{"yfinance":"VND=X"}), + "USDCNH": _s("USDCNH","USD/CNH","USD/CNH","Cặp tiền",{"twelvedata":"USD/CNH"}), + "USDHKD": _s("USDHKD","USD/HKD","USD/HKD","Cặp tiền",{"twelvedata":"USD/HKD"}), + "USDSGD": _s("USDSGD","USD/SGD","USD/SGD","Cặp tiền",{"twelvedata":"USD/SGD"}), # ══════════════════════════════════════════════════════════════════════ - # 7. CHỈ SỐ CHỨNG KHOÁN (Stock Indices) + # 7. CHỈ SỐ THẾ GIỚI (Global Indices) # ══════════════════════════════════════════════════════════════════════ - "SP500": _s("SP500","S&P 500","S&P 500","Chỉ số", - {"yfinance":"^GSPC","twelvedata":"SPY","finnhub":"INDEX:SPX"}, - desc="500 cổ phiếu vốn hóa lớn nhất Mỹ"), - "NASDAQ": _s("NASDAQ","Nasdaq Composite","Nasdaq Composite","Chỉ số", - {"yfinance":"^IXIC","twelvedata":"QQQ"}), - "NASDAQ100":_s("NASDAQ100","Nasdaq 100 (NDX)","Nasdaq 100","Chỉ số", - {"yfinance":"^NDX","twelvedata":"QQQ"}), - "DOW30": _s("DOW30","Dow Jones 30 (DJIA)","Dow Jones","Chỉ số", - {"yfinance":"^DJI","twelvedata":"DIA"}), - "RUSSELL2000":_s("RUSSELL2000","Russell 2000","Russell 2000","Chỉ số", - {"yfinance":"^RUT","twelvedata":"IWM"}), - "VIX": _s("VIX","VIX (Fear Index)","VIX","Chỉ số", - {"yfinance":"^VIX"}, - desc="Chỉ số biến động CBOE – thước đo nỗi sợ thị trường"), - "VNINDEX": _s("VNINDEX","VN-Index","VN-Index","Chỉ số", - {"yfinance":"^VNINDEX"}, - desc="Chỉ số thị trường chứng khoán Việt Nam – HOSE"), - "HNX30": _s("HNX30","HNX 30","HNX 30","Chỉ số", - {"yfinance":"^HNX30"}, - desc="30 cổ phiếu lớn nhất sàn Hà Nội"), - "UK100": _s("UK100","FTSE 100 (UK)","FTSE 100","Chỉ số", - {"yfinance":"^FTSE","finnhub":"INDEX:UKX"}), - "DAX40": _s("DAX40","DAX 40 (Đức)","DAX 40","Chỉ số", - {"yfinance":"^GDAXI","finnhub":"INDEX:DAX"}), - "EU50": _s("EU50","Euro Stoxx 50","Euro Stoxx 50","Chỉ số", - {"yfinance":"^STOXX50E"}), - "CAC40": _s("CAC40","CAC 40 (Pháp)","CAC 40","Chỉ số", - {"yfinance":"^FCHI"}), - "NIKKEI225":_s("NIKKEI225","Nikkei 225 (Nhật)","Nikkei 225","Chỉ số", - {"yfinance":"^N225"}), - "HSI": _s("HSI","Hang Seng (HK)","Hang Seng","Chỉ số", - {"yfinance":"^HSI"}), - "SSE50": _s("SSE50","SSE 50 (Thượng Hải)","SSE 50","Chỉ số", - {"yfinance":"000016.SS"}), - "CSI300": _s("CSI300","CSI 300 (Trung Quốc)","CSI 300","Chỉ số", - {"yfinance":"000300.SS"}), - "ASX200": _s("ASX200","ASX 200 (Úc)","ASX 200","Chỉ số", - {"yfinance":"^AXJO"}), - "SENSEX": _s("SENSEX","BSE Sensex (Ấn Độ)","Sensex","Chỉ số", - {"yfinance":"^BSESN"}), - "KOSPI": _s("KOSPI","KOSPI (Hàn Quốc)","KOSPI","Chỉ số", - {"yfinance":"^KS11"}), - "SGX": _s("SGX","Straits Times (Singapore)","STI","Chỉ số", - {"yfinance":"^STI"}), + "SP500": _s("SP500","S&P 500","S&P 500","Chỉ số",{"yfinance":"^GSPC","twelvedata":"SPX"}, bin_type="futures"), + "NASDAQ100":_s("NASDAQ100","Nasdaq 100","Nasdaq 100","Chỉ số",{"yfinance":"^NDX","twelvedata":"NDX"}, bin_type="futures"), + "DOW30": _s("DOW30","Dow Jones 30","Dow Jones","Chỉ số",{"yfinance":"^DJI"}), + "RUSSELL2000":_s("RUSSELL2000","Russell 2000","Russell 2000","Chỉ số",{"yfinance":"^RUT"}), + "VIX": _s("VIX","Chỉ số sợ hãi (VIX)","VIX Index","Chỉ số",{"yfinance":"^VIX"}), + "UK100": _s("UK100","FTSE 100 (UK)","FTSE 100","Chỉ số",{"yfinance":"^FTSE"}), + "DAX40": _s("DAX40","DAX 40 (Đức)","DAX 40","Chỉ số",{"yfinance":"^GDAXI"}), + "CAC40": _s("CAC40","CAC 40 (Pháp)","CAC 40","Chỉ số",{"yfinance":"^FCHI"}), + "EU50": _s("EU50","Euro Stoxx 50","Euro Stoxx 50","Chỉ số",{"yfinance":"^STOXX50E"}), + "NIKKEI225":_s("NIKKEI225","Nikkei 225 (Nhật)","Nikkei 225","Chỉ số",{"yfinance":"^N225"}), + "HSI": _s("HSI","Hang Seng (HK)","Hang Seng","Chỉ số",{"yfinance":"^HSI"}), + "VNINDEX": _s("VNINDEX","VN-Index","VN-Index","Chỉ số",{"yfinance":"^VNINDEX"}), + "HNXINDEX": _s("HNXINDEX","HNX-Index","HNX-Index","Chỉ số",{"yfinance":"^HNX"}), # ══════════════════════════════════════════════════════════════════════ - # 8. CỔ PHIẾU MỸ (US Stocks) + # 8. CỔ PHIẾU MỸ (US Stocks) # ══════════════════════════════════════════════════════════════════════ - "AAPL": _s("AAPL","Apple Inc.","Apple","Cổ phiếu Mỹ",{"yfinance":"AAPL","finnhub":"AAPL","twelvedata":"AAPL"}), - "MSFT": _s("MSFT","Microsoft Corp.","Microsoft","Cổ phiếu Mỹ",{"yfinance":"MSFT","finnhub":"MSFT","twelvedata":"MSFT"}), - "NVDA": _s("NVDA","Nvidia Corp.","Nvidia","Cổ phiếu Mỹ",{"yfinance":"NVDA","finnhub":"NVDA","twelvedata":"NVDA"}), - "GOOGL": _s("GOOGL","Alphabet (Google)","Google","Cổ phiếu Mỹ",{"yfinance":"GOOGL","finnhub":"GOOGL","twelvedata":"GOOGL"}), - "AMZN": _s("AMZN","Amazon.com","Amazon","Cổ phiếu Mỹ",{"yfinance":"AMZN","finnhub":"AMZN","twelvedata":"AMZN"}), - "META": _s("META","Meta Platforms","Meta","Cổ phiếu Mỹ",{"yfinance":"META","finnhub":"META","twelvedata":"META"}), - "TSLA": _s("TSLA","Tesla Inc.","Tesla","Cổ phiếu Mỹ",{"yfinance":"TSLA","finnhub":"TSLA","twelvedata":"TSLA"}), - "AVGO": _s("AVGO","Broadcom Inc.","Broadcom","Cổ phiếu Mỹ",{"yfinance":"AVGO","finnhub":"AVGO"}), - "JPM": _s("JPM","JPMorgan Chase","JPMorgan","Cổ phiếu Mỹ",{"yfinance":"JPM","finnhub":"JPM"}), - "V": _s("V","Visa Inc.","Visa","Cổ phiếu Mỹ",{"yfinance":"V","finnhub":"V"}), - "MA": _s("MA","Mastercard","Mastercard","Cổ phiếu Mỹ",{"yfinance":"MA","finnhub":"MA"}), - "XOM": _s("XOM","ExxonMobil","ExxonMobil","Cổ phiếu Mỹ",{"yfinance":"XOM","finnhub":"XOM"}), - "WMT": _s("WMT","Walmart","Walmart","Cổ phiếu Mỹ",{"yfinance":"WMT","finnhub":"WMT"}), - "BAC": _s("BAC","Bank of America","BofA","Cổ phiếu Mỹ",{"yfinance":"BAC","finnhub":"BAC"}), - "GS": _s("GS","Goldman Sachs","Goldman Sachs","Cổ phiếu Mỹ",{"yfinance":"GS","finnhub":"GS"}), - "AMD": _s("AMD","AMD (Advanced Micro Devices)","AMD","Cổ phiếu Mỹ",{"yfinance":"AMD","finnhub":"AMD"}), - "INTC": _s("INTC","Intel Corp.","Intel","Cổ phiếu Mỹ",{"yfinance":"INTC","finnhub":"INTC"}), - "NFLX": _s("NFLX","Netflix","Netflix","Cổ phiếu Mỹ",{"yfinance":"NFLX","finnhub":"NFLX"}), - "DIS": _s("DIS","Walt Disney","Disney","Cổ phiếu Mỹ",{"yfinance":"DIS","finnhub":"DIS"}), - "COIN": _s("COIN","Coinbase Global","Coinbase","Cổ phiếu Mỹ",{"yfinance":"COIN","finnhub":"COIN"}), - "MSTR": _s("MSTR","MicroStrategy (MSTR)","MicroStrategy","Cổ phiếu Mỹ",{"yfinance":"MSTR","finnhub":"MSTR"}), + "AAPL": _s("AAPL","Apple Inc.","Apple","Cổ phiếu Mỹ",{"yfinance":"AAPL"}), + "MSFT": _s("MSFT","Microsoft Corp.","Microsoft","Cổ phiếu Mỹ",{"yfinance":"MSFT"}), + "GOOGL": _s("GOOGL","Alphabet (Google)","Google","Cổ phiếu Mỹ",{"yfinance":"GOOGL"}), + "AMZN": _s("AMZN","Amazon.com","Amazon","Cổ phiếu Mỹ",{"yfinance":"AMZN"}), + "NVDA": _s("NVDA","Nvidia Corp.","Nvidia","Cổ phiếu Mỹ",{"yfinance":"NVDA"}), + "META": _s("META","Meta Platforms","Meta","Cổ phiếu Mỹ",{"yfinance":"META"}), + "TSLA": _s("TSLA","Tesla Inc.","Tesla","Cổ phiếu Mỹ",{"yfinance":"TSLA"}), + "AMD": _s("AMD","AMD","AMD","Cổ phiếu Mỹ",{"yfinance":"AMD"}), + "INTC": _s("INTC","Intel Corp.","Intel","Cổ phiếu Mỹ",{"yfinance":"INTC"}), + "TSM": _s("TSM","TSMC","TSMC","Cổ phiếu Mỹ",{"yfinance":"TSM"}), + "AVGO": _s("AVGO","Broadcom","Broadcom","Cổ phiếu Mỹ",{"yfinance":"AVGO"}), + "NFLX": _s("NFLX","Netflix","Netflix","Cổ phiếu Mỹ",{"yfinance":"NFLX"}), + "COIN": _s("COIN","Coinbase","Coinbase","Cổ phiếu Mỹ",{"yfinance":"COIN"}), + "MSTR": _s("MSTR","MicroStrategy","MicroStrategy","Cổ phiếu Mỹ",{"yfinance":"MSTR"}), + "JPM": _s("JPM","JPMorgan Chase","JPMorgan","Cổ phiếu Mỹ",{"yfinance":"JPM"}), + "GS": _s("GS","Goldman Sachs","Goldman Sachs","Cổ phiếu Mỹ",{"yfinance":"GS"}), + "V": _s("V","Visa Inc.","Visa","Cổ phiếu Mỹ",{"yfinance":"V"}), + "MA": _s("MA","Mastercard","Mastercard","Cổ phiếu Mỹ",{"yfinance":"MA"}), + "WMT": _s("WMT","Walmart","Walmart","Cổ phiếu Mỹ",{"yfinance":"WMT"}), + "XOM": _s("XOM","ExxonMobil","ExxonMobil","Cổ phiếu Mỹ",{"yfinance":"XOM"}), # ══════════════════════════════════════════════════════════════════════ - # 9. CỔ PHIẾU VIỆT NAM (Vietnam Stocks — HOSE/HNX via yfinance .VN) + # 9. CỔ PHIẾU VIỆT NAM (Vietnam Stocks) # ══════════════════════════════════════════════════════════════════════ "VCB": _s("VCB","Vietcombank (VCB)","Vietcombank","Cổ phiếu VN",{"yfinance":"VCB.VN"}), - "BID": _s("BID","BIDV (BID)","BIDV","Cổ phiếu VN",{"yfinance":"BID.VN"}), - "CTG": _s("CTG","VietinBank (CTG)","VietinBank","Cổ phiếu VN",{"yfinance":"CTG.VN"}), - "TCB": _s("TCB","Techcombank (TCB)","Techcombank","Cổ phiếu VN",{"yfinance":"TCB.VN"}), - "MBB": _s("MBB","MB Bank (MBB)","MB Bank","Cổ phiếu VN",{"yfinance":"MBB.VN"}), - "VPB": _s("VPB","VPBank (VPB)","VPBank","Cổ phiếu VN",{"yfinance":"VPB.VN"}), - "ACB": _s("ACB","ACB Bank (ACB)","ACB","Cổ phiếu VN",{"yfinance":"ACB.VN"}), - "HDB": _s("HDB","HDBank (HDB)","HDBank","Cổ phiếu VN",{"yfinance":"HDB.VN"}), - "SHB": _s("SHB","SHB Bank (SHB)","SHB","Cổ phiếu VN",{"yfinance":"SHB.VN"}), - "STB": _s("STB","Sacombank (STB)","Sacombank","Cổ phiếu VN",{"yfinance":"STB.VN"}), - "FPT": _s("FPT","FPT Corp (FPT)","FPT","Cổ phiếu VN",{"yfinance":"FPT.VN"}, - desc="Tập đoàn công nghệ FPT"), + "FPT": _s("FPT","FPT Corp (FPT)","FPT","Cổ phiếu VN",{"yfinance":"FPT.VN"}), "VIC": _s("VIC","Vingroup (VIC)","Vingroup","Cổ phiếu VN",{"yfinance":"VIC.VN"}), "VHM": _s("VHM","Vinhomes (VHM)","Vinhomes","Cổ phiếu VN",{"yfinance":"VHM.VN"}), - "VNM": _s("VNM","Vinamilk (VNM)","Vinamilk","Cổ phiếu VN",{"yfinance":"VNM.VN"}), - "MSN": _s("MSN","Masan Group (MSN)","Masan","Cổ phiếu VN",{"yfinance":"MSN.VN"}), - "GAS": _s("GAS","PV Gas (GAS)","PV Gas","Cổ phiếu VN",{"yfinance":"GAS.VN"}), - "SAB": _s("SAB","Sabeco (SAB)","Sabeco","Cổ phiếu VN",{"yfinance":"SAB.VN"}), "HPG": _s("HPG","Hòa Phát (HPG)","Hoa Phat Steel","Cổ phiếu VN",{"yfinance":"HPG.VN"}), - "MWG": _s("MWG","Thế Giới Di Động (MWG)","MWG","Cổ phiếu VN",{"yfinance":"MWG.VN"}), - "DGC": _s("DGC","Hóa chất Đức Giang (DGC)","DGC","Cổ phiếu VN",{"yfinance":"DGC.VN"}), - "PLX": _s("PLX","Petrolimex (PLX)","Petrolimex","Cổ phiếu VN",{"yfinance":"PLX.VN"}), - "POW": _s("POW","PV Power (POW)","PV Power","Cổ phiếu VN",{"yfinance":"POW.VN"}), - "SSI": _s("SSI","SSI Securities (SSI)","SSI","Cổ phiếu VN",{"yfinance":"SSI.VN"}), + "SSI": _s("SSI","Chứng khoán SSI","SSI","Cổ phiếu VN",{"yfinance":"SSI.VN"}), "VND": _s("VND","VNDirect (VND)","VNDirect","Cổ phiếu VN",{"yfinance":"VND.VN"}), - "HCM": _s("HCM","HCMC Securities (HCM)","HCM","Cổ phiếu VN",{"yfinance":"HCM.VN"}), + "MWG": _s("MWG","Thế Giới Di Động","MWG","Cổ phiếu VN",{"yfinance":"MWG.VN"}), + "VNM": _s("VNM","Vinamilk (VNM)","Vinamilk","Cổ phiếu VN",{"yfinance":"VNM.VN"}), + "MSN": _s("MSN","Masan Group","Masan","Cổ phiếu VN",{"yfinance":"MSN.VN"}), + "GAS": _s("GAS","PV GAS","PV GAS","Cổ phiếu VN",{"yfinance":"GAS.VN"}), + "BID": _s("BID","BIDV Bank","BIDV","Cổ phiếu VN",{"yfinance":"BID.VN"}), + "CTG": _s("CTG","VietinBank","VietinBank","Cổ phiếu VN",{"yfinance":"CTG.VN"}), + "TCB": _s("TCB","Techcombank","Techcombank","Cổ phiếu VN",{"yfinance":"TCB.VN"}), + "MBB": _s("MBB","MB Bank","MB Bank","Cổ phiếu VN",{"yfinance":"MBB.VN"}), + "STB": _s("STB","Sacombank","Sacombank","Cổ phiếu VN",{"yfinance":"STB.VN"}), + "ACB": _s("ACB","ACB Bank","ACB","Cổ phiếu VN",{"yfinance":"ACB.VN"}), + "HDB": _s("HDB","HDBank","HDBank","Cổ phiếu VN",{"yfinance":"HDB.VN"}), + "VPB": _s("VPB","VPBank","VPBank","Cổ phiếu VN",{"yfinance":"VPB.VN"}), + "DGC": _s("DGC","Hóa chất Đức Giang","DGC","Cổ phiếu VN",{"yfinance":"DGC.VN"}), # ══════════════════════════════════════════════════════════════════════ - # 10. TRÁI PHIẾU & LÃI SUẤT (Bonds & Rates) + # 10. TRÁI PHIẾU & ETF # ══════════════════════════════════════════════════════════════════════ - "US10Y": _s("US10Y","Trái phiếu Mỹ 10 năm","US 10Y Treasury","Trái phiếu", - {"yfinance":"^TNX"}, - desc="Lợi suất trái phiếu kho bạc Mỹ kỳ hạn 10 năm – chuẩn lãi suất toàn cầu"), - "US02Y": _s("US02Y","Trái phiếu Mỹ 2 năm","US 2Y Treasury","Trái phiếu", - {"yfinance":"^IRX"}), - "US30Y": _s("US30Y","Trái phiếu Mỹ 30 năm","US 30Y Treasury","Trái phiếu", - {"yfinance":"^TYX"}), - "DE10Y": _s("DE10Y","Bund Đức 10 năm","DE 10Y Bund","Trái phiếu", - {"yfinance":"^DE10YT=RR"}), - "JP10Y": _s("JP10Y","JGB Nhật 10 năm","JP 10Y JGB","Trái phiếu", - {"yfinance":"^JP10YT=RR"}), + "US10Y": _s("US10Y","Trái phiếu Mỹ 10Y","US 10Y Treasury","Trái phiếu",{"yfinance":"^TNX"}), + "US02Y": _s("US02Y","Trái phiếu Mỹ 2Y","US 2Y Treasury","Trái phiếu",{"yfinance":"^IRX"}), + "SPY": _s("SPY","SPDR S&P 500 ETF","SPY","ETF",{"yfinance":"SPY"}), + "QQQ": _s("QQQ","Invesco QQQ Trust","QQQ","ETF",{"yfinance":"QQQ"}), + "DIA": _s("DIA","Dow Jones ETF","DIA","ETF",{"yfinance":"DIA"}), } @@ -835,6 +708,11 @@ historical_cache = TTLCache() forecast_cache = TTLCache() ticker_cache = TTLCache() +# Explicitly clear on startup to ensure fresh v6.1+ format +historical_cache.clear() +forecast_cache.clear() +ticker_cache.clear() + def _cache_prefix(symbol: str, interval: str) -> str: return f"{CACHE_VERSION}:{symbol}:{interval}:" @@ -878,10 +756,12 @@ def _normalize_ohlcv(records: List[Dict[str, Any]], interval: str = "1h") -> Lis else: threshold = 0.0001 - # B-1: Dynamic adaptive zombie-candle threshold - # Instead of hardcoded values, use a permissive baseline and dynamic check in v5 - vol_threshold = threshold - + # B-1: Dynamic adaptive zombie-candle threshold (BUG-07 Fix) + # Reduce threshold further for specific low-volatility asset classes + symbol_cfg = None + if records and "symbol" in records[0]: # Not always present in raw rows + pass + normalized: List[Dict[str, Any]] = [] for row in records: try: @@ -894,9 +774,10 @@ def _normalize_ohlcv(records: List[Dict[str, Any]], interval: str = "1h") -> Lis if any(math.isnan(x) or math.isinf(x) for x in [o, h, l, c, v]): continue - # Permissive filter: only discard if range is truly zero or extremely suspicious (< 0.00001) - # This prevents discarding JPY pairs or US bonds (BUG-07) - if o > 0 and (h - l) / o < 1e-6: + # Adaptive threshold: permit smaller ranges for shorter timeframes + # and specifically for assets where o > 0. + # 1e-9 is effectively "almost zero" to catch true dead candles while preserving JPY/Bonds. + if o > 0 and (h - l) / o < 1e-9: continue normalized.append({"time": t, "open": o, "high": h, "low": l, "close": c, "volume": v}) @@ -920,13 +801,17 @@ def _normalize_ohlcv(records: List[Dict[str, Any]], interval: str = "1h") -> Lis async def fetch_binance(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]: await _rate_limit("binance") - endpoint_symbol = SYMBOLS[symbol].mappings["binance"] + cfg = SYMBOLS[symbol] + endpoint_symbol = cfg.mappings["binance"] + base_url = "https://fapi.binance.com" if cfg.binance_type == "futures" else "https://api.binance.com" + endpoint = "/fapi/v1/klines" if cfg.binance_type == "futures" else "/api/v3/klines" + params = { "symbol": endpoint_symbol, "interval": BINANCE_INTERVAL_MAP.get(interval, interval), "limit": min(max(limit, 30), 1000), } - logger.info("[Binance] %s %s", symbol, interval) + logger.info("[Binance] %s %s (%s)", symbol, interval, cfg.binance_type) async def _fetch(): cb = source_breakers["binance"] @@ -935,7 +820,7 @@ async def fetch_binance(symbol: str, interval: str, limit: int) -> List[Dict[str try: client = await GlobalHTTPClient.get_client() - resp = await client.get("https://api.binance.com/api/v3/klines", params=params) + resp = await client.get(f"{base_url}{endpoint}", params=params) if resp.status_code == 429: cb.record_failure() raise HTTPException(status_code=429, detail="Binance rate limit") @@ -967,7 +852,7 @@ async def fetch_bybit(symbol: str, interval: str, limit: int) -> List[Dict[str, "category": bybit_cat, "symbol": endpoint_symbol, "interval": BYBIT_INTERVAL_MAP.get(interval, "60"), - "limit": min(max(limit, 1), 200), + "limit": min(max(limit, 1), 1000), # Bybit V5 supports up to 1000 } logger.info("[Bybit] %s %s (cat=%s)", symbol, interval, bybit_cat) @@ -978,7 +863,31 @@ async def fetch_bybit(symbol: str, interval: str, limit: int) -> List[Dict[str, try: client = await GlobalHTTPClient.get_client() - resp = await client.get("https://api.bybit.com/v5/market/kline", params=params) + url = "https://api.bybit.com/v5/market/kline" + + # Authenticate if keys are available + headers = {} + query_params = params.copy() + + if settings.bybit_api_key and settings.bybit_api_secret: + timestamp = str(int(time.time() * 1000)) + recv_window = "5000" + # For GET, sort params alphabetically and join + sorted_params = "&".join([f"{k}={v}" for k, v in sorted(query_params.items())]) + raw_str = timestamp + settings.bybit_api_key + recv_window + sorted_params + signature = hmac.new(settings.bybit_api_secret.encode('utf-8'), + raw_str.encode('utf-8'), hashlib.sha256).hexdigest() + + headers = { + 'X-BAPI-API-KEY': settings.bybit_api_key, + 'X-BAPI-TIMESTAMP': timestamp, + 'X-BAPI-SIGN-TYPE': '2', + 'X-BAPI-RECV-WINDOW': recv_window, + 'X-BAPI-SIGN': signature + } + + resp = await client.get(url, params=query_params, headers=headers) + if resp.status_code == 429: cb.record_failure() raise HTTPException(status_code=429, detail="Bybit rate limit") @@ -996,7 +905,6 @@ async def fetch_bybit(symbol: str, interval: str, limit: int) -> List[Dict[str, if data.get("retCode", -1) != 0: raise RuntimeError(f"Bybit: {data}") - data = await _retry(_fetch) rows = data.get("result", {}).get("list", []) # Bybit returns: [startTime, open, high, low, close, volume, turnover] parsed = [ @@ -1111,54 +1019,46 @@ async def fetch_twelvedata(symbol: str, interval: str, limit: int) -> List[Dict[ async def fetch_finnhub(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]: - await _rate_limit("finnhub") - endpoint_symbol = SYMBOLS[symbol].mappings["finnhub"] - resolution = FINNHUB_RESOLUTION_MAP[interval] - now = int(time.time()) - # BUG-14: Limit lookback to prevent overflow or errors on long intervals (1w) - # Cap lookback at 5 years (approx 157,788,000 seconds) - max_lookback = 5 * 365 * 86400 - lookback = min(int(STEP_SECONDS[interval] * max(limit * 3, 150)), max_lookback) - - if endpoint_symbol.startswith("OANDA:"): - url = "https://finnhub.io/api/v1/forex/candle" - elif endpoint_symbol.startswith("BINANCE:"): - url = "https://finnhub.io/api/v1/crypto/candle" - elif endpoint_symbol.startswith("INDEX:"): - url = "https://finnhub.io/api/v1/index/historical" - else: - url = "https://finnhub.io/api/v1/stock/candle" + mappings = SYMBOLS[symbol].mappings + if "finnhub" not in mappings: + return [] - params = { - "symbol": endpoint_symbol, - "resolution": resolution, - "from": now - lookback, - "to": now, - "token": FINNHUB_API_KEY, - } - logger.info("[Finnhub] %s %s", symbol, interval) - - async def _fetch(): - async with httpx.AsyncClient(timeout=20) as client: - resp = await client.get(url, params=params) - if resp.status_code == 429: - raise HTTPException(status_code=429, detail="Finnhub rate limit") - return resp.json() + cb = source_breakers.get("finnhub") + if cb and not cb.allow_request(): + return [] - payload = await _retry(_fetch) - if payload.get("s") != "ok": - raise RuntimeError(f"Finnhub: {payload}") + await _rate_limit("finnhub") + client = await GlobalHTTPClient.get_client() + + async def _call(): + r = await client.get( + "https://finnhub.io/api/v1/stock/candle", + params={ + "symbol": mappings["finnhub"], + "resolution": FINNHUB_RESOLUTION_MAP.get(interval, "D"), + "count": limit, + "token": FINNHUB_API_KEY, + }, + timeout=10, + ) + r.raise_for_status() + return r.json() - timestamps = payload.get("t", []) - n = len(timestamps) - volumes = payload.get("v") or [0.0] * n - parsed = [ - {"time": timestamps[i], "open": payload["o"][i], "high": payload["h"][i], - "low": payload["l"][i], "close": payload["c"][i], - "volume": volumes[i] if i < len(volumes) else 0.0} - for i in range(n) - ] - return _normalize_ohlcv(parsed, interval)[-limit:] + try: + d = await _retry(_call) + if d.get("s") != "ok": + if cb: cb.record_success() + return [] + + if cb: cb.record_success() + return [ + {"time": t, "open": o, "high": h, "low": l, "close": c, "volume": v} + for t, o, h, l, c, v in zip(d["t"], d["o"], d["h"], d["l"], d["c"], d["v"]) + ] + except Exception as ex: + if cb: cb.record_failure() + logger.warning("[finnhub] %s failed: %s", symbol, ex) + return [] def _resample_4h(df: pd.DataFrame) -> pd.DataFrame: @@ -1178,7 +1078,9 @@ async def fetch_yfinance(symbol: str, interval: str, limit: int) -> List[Dict[st logger.info("[yfinance] %s %s", symbol, interval) def _download() -> pd.DataFrame: - return yf.download(tickers=ticker, interval=yf_interval, period=period, + # B-5: Robust lookback (v6.0) - ensure EMA200 context + p = "2y" if interval == "1d" else "60d" if "m" in interval else "1y" + return yf.download(tickers=ticker, interval=yf_interval, period=p, progress=False, auto_adjust=False, threads=False) df = await asyncio.to_thread(_download) @@ -1202,6 +1104,46 @@ async def fetch_yfinance(symbol: str, interval: str, limit: int) -> List[Dict[st return _normalize_ohlcv(parsed, interval)[-limit:] +async def fetch_alphavantage(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]: + """Alpha Vantage - primarily for Macro/Historical. Needs API key.""" + if not settings.alphavantage_api_key: + raise ValueError("Alpha Vantage API key missing") + + await _rate_limit("alphavantage") + endpoint_symbol = SYMBOLS[symbol].mappings.get("alphavantage", symbol) + + # AV has specific functions for daily/intraday + func = "TIME_SERIES_INTRADAY" if interval in ["1m", "5m", "15m", "60min"] else "TIME_SERIES_DAILY" + url = f"https://www.alphavantage.co/query?function={func}&symbol={endpoint_symbol}&apikey={settings.alphavantage_api_key}&outputsize=full" + if "INTRADAY" in func: + url += f"&interval={interval if interval != '1h' else '60min'}" + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url) + data = resp.json() + + # Parse logic based on AV's weird nested JSON + key = next((k for k in data.keys() if "Time Series" in k), None) + if not key: + raise RuntimeError(f"Alpha Vantage: {data.get('Note', data.get('Information', 'Unknown error'))}") + + series = data[key] + parsed = [] + for ts, vals in series.items(): + parsed.append({ + "time": int(datetime.strptime(ts, "%Y-%m-%d %H:%M:%S" if " " in ts else "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()), + "open": float(vals["1. open"]), + "high": float(vals["2. high"]), + "low": float(vals["3. low"]), + "close": float(vals["4. close"]), + "volume": float(vals.get("5. volume", 0)) + }) + parsed.sort(key=lambda x: x["time"]) + return _normalize_ohlcv(parsed, interval)[-limit:] + + + + def _get_source_priority(symbol: str) -> List[str]: cfg = SYMBOLS[symbol] priority = CATEGORY_SOURCE_PRIORITY.get(cfg.category, DEFAULT_SOURCE_PRIORITY) @@ -1211,35 +1153,50 @@ def _get_source_priority(symbol: str) -> List[str]: async def fetch_historical( symbol: str, interval: str, limit: int ) -> Tuple[List[Dict[str, Any]], str]: + """ + Fetch OHLCV data with fallback and caching. + v6.0: Cache strategy optimized - always fetch max context, cache once. + """ prefix = _cache_prefix(symbol, interval) - key = f"hist_{prefix}{limit}" + key = f"hist_{prefix}" # BUG-P1-03: No limit in key to increase cache hits cached = historical_cache.get(key) if cached is not None: - return cached, "cache" + try: + # v6.1: Cache stores (data, source) + data_cached, source_cached = cached + return data_cached[-limit:], source_cached + except (ValueError, TypeError): + # Handle old cache format gracefully + historical_cache.delete(key) priority = _get_source_priority(symbol) errors: List[str] = [] + # Fetch more than needed to ensure indicators have enough context (v6.0) + fetch_limit = max(limit, 1000) + for source in priority: try: if source == "binance": - data = await fetch_binance(symbol, interval, limit) + data = await fetch_binance(symbol, interval, fetch_limit) elif source == "bybit": - data = await fetch_bybit(symbol, interval, limit) + data = await fetch_bybit(symbol, interval, fetch_limit) elif source == "coingecko": - data = await fetch_coingecko(symbol, interval, limit) + data = await fetch_coingecko(symbol, interval, fetch_limit) elif source == "twelvedata": - data = await fetch_twelvedata(symbol, interval, limit) + data = await fetch_twelvedata(symbol, interval, fetch_limit) elif source == "finnhub": - data = await fetch_finnhub(symbol, interval, limit) + data = await fetch_finnhub(symbol, interval, fetch_limit) elif source == "yfinance": - data = await fetch_yfinance(symbol, interval, limit) + data = await fetch_yfinance(symbol, interval, fetch_limit) + elif source == "alphavantage": + data = await fetch_alphavantage(symbol, interval, fetch_limit) else: continue if len(data) >= 20: - historical_cache.set(key, data, ttl_seconds=interval_ttl(interval)) - return data, source + historical_cache.set(key, (data, source), ttl_seconds=interval_ttl(interval)) + return data[-limit:], source errors.append(f"{source}: insufficient ({len(data)} candles)") except HTTPException: raise @@ -1258,78 +1215,103 @@ async def fetch_historical( # ────────────────────────────────────────────────────────────────────────────── async def fetch_ticker(symbol: str) -> Dict[str, Any]: cached = ticker_cache.get(f"ticker:{symbol}") - if cached: - return cached - - cfg = SYMBOLS[symbol] - result = {} - category = cfg.category + if cached: return cached - try: - if "binance" in cfg.mappings and category == "Crypto": - await _rate_limit("binance") - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get( - "https://api.binance.com/api/v3/ticker/24hr", - params={"symbol": cfg.mappings["binance"]}, - ) - d = r.json() - result = { - "price": float(d["lastPrice"]), - "change": float(d["priceChange"]), - "change_pct": float(d["priceChangePercent"]), - "high_24h": float(d["highPrice"]), - "low_24h": float(d["lowPrice"]), - "volume_24h": float(d["volume"]), - "source": "binance", - } - else: - # yfinance history for robust prev_close - def _yf_info(): - symbol_mapped = cfg.mappings.get("yfinance", "") - t = yf.Ticker(symbol_mapped) - # Fetch 5d to ensure we have at least one previous close even across weekends/holidays - h = t.history(period="5d") - if h.empty: - # Fallback to fast_info if history fails - fi = t.fast_info - return { - "price": getattr(fi, "last_price", 0) or 0, - "prev_close": getattr(fi, "previous_close", None), - "source": "yfinance_fast" - } - - last_price = float(h["Close"].iloc[-1]) - prev_close = float(h["Close"].iloc[-2]) if len(h) > 1 else last_price + cfg = SYMBOLS[symbol] + priority = _get_source_priority(symbol) + + for source in priority: + try: + if source == "binance": + await _rate_limit("binance") + base_url = "https://fapi.binance.com" if cfg.binance_type == "futures" else "https://api.binance.com" + endpoint = "/fapi/v1/ticker/24hr" if cfg.binance_type == "futures" else "/api/v3/ticker/24hr" - return { - "price": last_price, - "prev_close": prev_close, - "high_24h": float(h["High"].iloc[-1]), - "low_24h": float(h["Low"].iloc[-1]), - "volume_24h": float(h["Volume"].iloc[-1]), - "source": "yfinance_hist", + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"{base_url}{endpoint}", + params={"symbol": cfg.mappings["binance"]}) + d = r.json() + # Futures and Spot use slightly different field names in some cases, but 24hr is mostly consistent + res = { + "price": float(d.get("lastPrice") or d.get("price") or 0), + "change": float(d.get("priceChange", 0)), + "change_pct": float(d.get("priceChangePercent", 0)), + "high_24h": float(d.get("highPrice", 0)), + "low_24h": float(d.get("lowPrice", 0)), + "volume_24h": float(d.get("volume", 0)), + "source": "binance" } + elif source == "twelvedata": + await _rate_limit("twelvedata") + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get("https://api.twelvedata.com/quote", + params={"symbol": cfg.mappings["twelvedata"], "apikey": TWELVEDATA_API_KEY}) + d = r.json() + if "close" not in d and "price" not in d: continue + p = float(d.get("price") or d.get("close") or 0) + pc = float(d.get("previous_close") or p) + res = { + "price": p, "change": p - pc, "change_pct": ((p - pc)/pc*100) if pc!=0 else 0, + "high_24h": float(d.get("high") or p), "low_24h": float(d.get("low") or p), + "volume_24h": float(d.get("volume") or 0), "source": "twelvedata" + } + elif source == "bybit": + await _rate_limit("bybit") + async with httpx.AsyncClient(timeout=10) as c: + url = "https://api.bybit.com/v5/market/tickers" + query_params = {"category": cfg.bybit_category, "symbol": cfg.mappings["bybit"]} + headers = {} + + if settings.bybit_api_key and settings.bybit_api_secret: + timestamp = str(int(time.time() * 1000)) + recv_window = "5000" + sorted_params = "&".join([f"{k}={v}" for k, v in sorted(query_params.items())]) + raw_str = timestamp + settings.bybit_api_key + recv_window + sorted_params + signature = hmac.new(settings.bybit_api_secret.encode('utf-8'), + raw_str.encode('utf-8'), hashlib.sha256).hexdigest() + headers = { + 'X-BAPI-API-KEY': settings.bybit_api_key, + 'X-BAPI-TIMESTAMP': timestamp, + 'X-BAPI-SIGN-TYPE': '2', + 'X-BAPI-RECV-WINDOW': recv_window, + 'X-BAPI-SIGN': signature + } + + r = await c.get(url, params=query_params, headers=headers) + d = r.json() + if d.get("retCode") == 0 and d.get("result", {}).get("list"): + t = d["result"]["list"][0] + res = { + "price": float(t["lastPrice"]), "change": float(t["price24hPcnt"]) * float(t["lastPrice"]) / 100, + "change_pct": float(t["price24hPcnt"]) * 100, "high_24h": float(t["highPrice24h"]), + "low_24h": float(t["lowPrice24h"]), "volume_24h": float(t["volume24h"]), "source": "bybit" + } + elif source == "yfinance": + def _yf_info(): + t = yf.Ticker(cfg.mappings["yfinance"]) + h = t.history(period="5d") + if h.empty: return None + lp, pc = float(h["Close"].iloc[-1]), float(h["Close"].iloc[-2]) if len(h)>1 else float(h["Close"].iloc[-1]) + return { + "price": lp, "change": lp - pc, "change_pct": ((lp - pc)/pc*100) if pc!=0 else 0, + "high_24h": float(h["High"].iloc[-1]), "low_24h": float(h["Low"].iloc[-1]), + "volume_24h": float(h["Volume"].iloc[-1]), "source": "yfinance" + } + res = await asyncio.to_thread(_yf_info) + if not res: continue + else: + continue - info = await asyncio.to_thread(_yf_info) - price = info.get("price") or 0 - prev = info.get("prev_close") - - # If prev is still None, use price (result 0%) as ultimate fallback - if prev is None: prev = price - - info["change"] = price - prev - info["change_pct"] = ((price - prev) / prev * 100) if prev != 0 else 0 - result = info - - result["symbol"] = symbol - result["timestamp"] = int(time.time()) - ticker_cache.set(f"ticker:{symbol}", result, ttl_seconds=15) - return result + res.update({"symbol": symbol, "timestamp": int(time.time())}) + ttl = 5 if cfg.category == "Crypto" else (10 if cfg.category in ("Cặp tiền", "Chỉ số") else 30) + ticker_cache.set(f"ticker:{symbol}", res, ttl_seconds=ttl) + return res + except Exception as ex: + logger.debug("[ticker] %s/%s failed: %s", symbol, source, ex) + continue - except Exception as ex: - logger.warning("[ticker] %s failed: %s", symbol, ex) - raise HTTPException(status_code=502, detail=f"Ticker fetch failed: {ex}") + # Ultimate Fallback + raise HTTPException(status_code=502, detail=f"Ticker failed for {symbol} after trying {priority}") # ────────────────────────────────────────────────────────────────────────────── @@ -1340,7 +1322,7 @@ def _ema(arr: np.ndarray, period: int) -> np.ndarray: """Vectorized EMA using NumPy (replaces loops).""" if len(arr) == 0: return np.array([], dtype=float) alpha = 2.0 / (period + 1.0) - # Use pandas ewm for robust vectorized calculation (standard industry practice) + # Use pandas ewm for robust vectorized calculation (v6.0) return pd.Series(arr).ewm(alpha=alpha, adjust=False).mean().values def _rsi(close: np.ndarray, period: int = 14) -> np.ndarray: @@ -1365,14 +1347,6 @@ def _bollinger(close: np.ndarray, period=20, k=2.0) -> Tuple[np.ndarray, np.ndar return (mid + k*std).values, mid.values, (mid - k*std).values -def _bollinger(close: np.ndarray, period=20, k=2.0) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - """Vectorized Bollinger Bands.""" - s = pd.Series(close) - mid = s.rolling(window=period).mean() - std = s.rolling(window=period).std() - return (mid + k*std).values, mid.values, (mid - k*std).values - - def _macd(close: np.ndarray, fast=12, slow=26, signal=9 ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: ema_fast = _ema(close, fast) @@ -1384,103 +1358,61 @@ def _macd(close: np.ndarray, fast=12, slow=26, signal=9 def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, period=14) -> np.ndarray: - tr = np.maximum(high[1:] - low[1:], - np.maximum(np.abs(high[1:] - close[:-1]), - np.abs(low[1:] - close[:-1]))) - atr = np.full(len(close), np.nan) - if len(tr) < period: - return atr - atr[period] = np.mean(tr[:period]) - for i in range(period + 1, len(close)): - atr[i] = (atr[i - 1] * (period - 1) + tr[i - 1]) / period - return atr + """Vectorized ATR.""" + tr = np.maximum(high[1:] - low[1:], + np.maximum(np.abs(high[1:] - close[:-1]), + np.abs(low[1:] - close[:-1]))) + tr = np.concatenate([[np.nan], tr]) + return pd.Series(tr).ewm(alpha=1.0/period, adjust=False).mean().values def _stoch_rsi(close: np.ndarray, rsi_period=14, stoch_period=14, smooth_k=3, smooth_d=3) -> Tuple[np.ndarray, np.ndarray]: - rsi_vals = _rsi(close, rsi_period) - k = np.full_like(close, np.nan) - for i in range(stoch_period - 1, len(rsi_vals)): - window = rsi_vals[i - stoch_period + 1: i + 1] - if np.any(np.isnan(window)): - continue - lo, hi = np.min(window), np.max(window) - k[i] = (rsi_vals[i] - lo) / (hi - lo) * 100 if hi != lo else 50 - k_smooth = _ema(np.where(np.isnan(k), 50, k), smooth_k) - d_smooth = _ema(np.where(np.isnan(k_smooth), 50, k_smooth), smooth_d) - return k_smooth, d_smooth + """Vectorized Stochastic RSI.""" + rsi_vals = pd.Series(_rsi(close, rsi_period)) + roll_min = rsi_vals.rolling(window=stoch_period).min() + roll_max = rsi_vals.rolling(window=stoch_period).max() + + k = 100 * (rsi_vals - roll_min) / (roll_max - roll_min).replace(0, np.inf) + k_smooth = k.rolling(window=smooth_k).mean() + d_smooth = k_smooth.rolling(window=smooth_d).mean() + return k_smooth.values, d_smooth.values def _sma(arr: np.ndarray, period: int) -> np.ndarray: - """Simple Moving Average.""" - out = np.full_like(arr, np.nan) - for i in range(period - 1, len(arr)): - out[i] = np.mean(arr[i - period + 1: i + 1]) - return out + """Vectorized Simple Moving Average.""" + if len(arr) == 0: return np.array([], dtype=float) + return pd.Series(arr).rolling(window=period).mean().values def _cci(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 20) -> np.ndarray: - """Commodity Channel Index.""" + """Vectorized Commodity Channel Index.""" tp = (high + low + close) / 3.0 - out = np.full_like(close, np.nan) - for i in range(period - 1, len(tp)): - window = tp[i - period + 1: i + 1] - sma_val = np.mean(window) - mean_dev = np.mean(np.abs(window - sma_val)) - out[i] = (tp[i] - sma_val) / (0.015 * mean_dev) if mean_dev > 0 else 0.0 - return out - - -def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14 - ) -> tuple: - """Average Directional Index. Returns (adx, plus_di, minus_di).""" - n = len(close) - plus_dm = np.zeros(n) - minus_dm = np.zeros(n) - tr = np.zeros(n) - - for i in range(1, n): - up = high[i] - high[i - 1] - dn = low[i - 1] - low[i] - plus_dm[i] = up if (up > dn and up > 0) else 0.0 - minus_dm[i] = dn if (dn > up and dn > 0) else 0.0 - tr[i] = max(high[i] - low[i], abs(high[i] - close[i - 1]), abs(low[i] - close[i - 1])) - - atr_arr = np.full(n, np.nan) - plus_di_arr = np.full(n, np.nan) - minus_di_arr = np.full(n, np.nan) - dx_arr = np.full(n, np.nan) - adx_arr = np.full(n, np.nan) - - if n <= period: - return adx_arr, plus_di_arr, minus_di_arr - - atr_arr[period] = np.mean(tr[1:period + 1]) - sm_plus = np.mean(plus_dm[1:period + 1]) - sm_minus = np.mean(minus_dm[1:period + 1]) - - for i in range(period + 1, n): - atr_arr[i] = (atr_arr[i - 1] * (period - 1) + tr[i]) / period - sm_plus = (sm_plus * (period - 1) + plus_dm[i]) / period - sm_minus = (sm_minus * (period - 1) + minus_dm[i]) / period - - plus_di_arr[i] = (sm_plus / atr_arr[i]) * 100 if atr_arr[i] > 0 else 0 - minus_di_arr[i] = (sm_minus / atr_arr[i]) * 100 if atr_arr[i] > 0 else 0 - - di_sum = plus_di_arr[i] + minus_di_arr[i] - dx_arr[i] = abs(plus_di_arr[i] - minus_di_arr[i]) / di_sum * 100 if di_sum > 0 else 0 - - # B-5: Critical fix for ADX IndexError and logic (BUG-11) - # Ensure start_idx doesn't exceed n and at least 1 DX value is used - valid_dx = [dx_arr[i] for i in range(period + 1, min(2 * period + 1, n)) if not np.isnan(dx_arr[i])] - if valid_dx and len(valid_dx) > 0: - start_idx = min(period + len(valid_dx), n - 1) - adx_arr[start_idx] = np.mean(valid_dx) - for i in range(start_idx + 1, n): - if not np.isnan(dx_arr[i]): - adx_arr[i] = (adx_arr[i - 1] * (period - 1) + dx_arr[i]) / period - - return adx_arr, plus_di_arr, minus_di_arr + tp_series = pd.Series(tp) + sma = tp_series.rolling(window=period).mean() + # Optimized MAD calculation (Vectorized) + mad = tp_series.rolling(window=period).apply(lambda x: np.abs(x - x.mean()).mean(), raw=True) + return ((tp_series - sma) / (0.015 * mad.replace(0, np.inf))).values + + +def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> tuple: + """Vectorized Average Directional Index (v6.0).""" + plus_dm = np.where((high[1:] - high[:-1] > low[:-1] - low[1:]) & (high[1:] - high[:-1] > 0), high[1:] - high[:-1], 0.0) + minus_dm = np.where((low[:-1] - low[1:] > high[1:] - high[:-1]) & (low[:-1] - low[1:] > 0), low[:-1] - low[1:], 0.0) + tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1]))) + + # Pad first index + plus_dm = np.concatenate([[0.0], plus_dm]) + minus_dm = np.concatenate([[0.0], minus_dm]) + tr = np.concatenate([[0.0], tr]) + + tr_sum = pd.Series(tr).ewm(alpha=1.0/period, adjust=False).mean() + plus_di = 100 * pd.Series(plus_dm).ewm(alpha=1.0/period, adjust=False).mean() / tr_sum.replace(0, np.inf) + minus_di = 100 * pd.Series(minus_dm).ewm(alpha=1.0/period, adjust=False).mean() / tr_sum.replace(0, np.inf) + + dx = 100 * np.abs(plus_di - minus_di) / (plus_di + minus_di).replace(0, np.inf) + adx = dx.ewm(alpha=1.0/period, adjust=False).mean() + return adx.values, plus_di.values, minus_di.values def _awesome_oscillator(high: np.ndarray, low: np.ndarray) -> np.ndarray: @@ -1492,74 +1424,63 @@ def _awesome_oscillator(high: np.ndarray, low: np.ndarray) -> np.ndarray: def _momentum(close: np.ndarray, period: int = 10) -> np.ndarray: - """Momentum = close - close[n periods ago].""" - out = np.full_like(close, np.nan) - for i in range(period, len(close)): - out[i] = close[i] - close[i - period] - return out + """Vectorized Momentum: close - close[n periods ago].""" + return pd.Series(close).diff(period).values def _williams_r(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray: - """Williams %R.""" - out = np.full_like(close, np.nan) - for i in range(period - 1, len(close)): - hh = np.max(high[i - period + 1: i + 1]) - ll = np.min(low[i - period + 1: i + 1]) - out[i] = -100 * (hh - close[i]) / (hh - ll) if (hh - ll) > 0 else 0.0 - return out + """Vectorized Williams %R.""" + s = pd.Series(close) + hh = pd.Series(high).rolling(window=period).max() + ll = pd.Series(low).rolling(window=period).min() + wr = -100 * (hh - s) / (hh - ll).replace(0, np.inf) + return wr.values def _bull_bear_power(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 13) -> np.ndarray: - """Bull Bear Power = Bull Power + Bear Power.""" + """Vectorized Bull Bear Power (v6.0).""" ema_val = _ema(close, period) - bull = high - ema_val - bear = low - ema_val - return bull + bear + return (high - ema_val) + (low - ema_val) def _ultimate_oscillator(high: np.ndarray, low: np.ndarray, close: np.ndarray, p1: int = 7, p2: int = 14, p3: int = 28) -> np.ndarray: - """Ultimate Oscillator (7, 14, 28).""" + """Vectorized Ultimate Oscillator (v6.0).""" n = len(close) - out = np.full(n, np.nan) - if n < p3 + 1: - return out + if n < p3 + 1: return np.full(n, np.nan) - bp = np.zeros(n) - tr = np.zeros(n) - for i in range(1, n): - tl = min(low[i], close[i - 1]) - bp[i] = close[i] - tl - tr[i] = max(high[i], close[i - 1]) - tl + prev_close = pd.Series(close).shift(1) + tl = np.minimum(low, prev_close.values) + th = np.maximum(high, prev_close.values) + + bp = pd.Series(close - tl) + tr = pd.Series(th - tl) + + def _avg(p): + return bp.rolling(p).sum() / tr.rolling(p).sum().replace(0, np.inf) - for i in range(p3, n): - s1 = np.sum(bp[i - p1 + 1:i + 1]) / max(np.sum(tr[i - p1 + 1:i + 1]), 1e-10) - s2 = np.sum(bp[i - p2 + 1:i + 1]) / max(np.sum(tr[i - p2 + 1:i + 1]), 1e-10) - s3 = np.sum(bp[i - p3 + 1:i + 1]) / max(np.sum(tr[i - p3 + 1:i + 1]), 1e-10) - out[i] = 100 * (4 * s1 + 2 * s2 + s3) / 7.0 - return out + avg1 = _avg(p1) + avg2 = _avg(p2) + avg3 = _avg(p3) + + uo = 100 * (4 * avg1 + 2 * avg2 + avg3) / 7.0 + return uo.values def _ichimoku_base(high: np.ndarray, low: np.ndarray, period: int = 26) -> np.ndarray: - """Ichimoku Base Line (Kijun-sen).""" - out = np.full_like(high, np.nan) - for i in range(period - 1, len(high)): - hh = np.max(high[i - period + 1: i + 1]) - ll = np.min(low[i - period + 1: i + 1]) - out[i] = (hh + ll) / 2.0 - return out + """Vectorized Ichimoku Base Line.""" + hh = pd.Series(high).rolling(window=period).max() + ll = pd.Series(low).rolling(window=period).min() + return ((hh + ll) / 2.0).values def _vwma(close: np.ndarray, volume: np.ndarray, period: int = 20) -> np.ndarray: - """Volume Weighted Moving Average.""" - out = np.full_like(close, np.nan) - for i in range(period - 1, len(close)): - cv = close[i - period + 1: i + 1] * volume[i - period + 1: i + 1] - v_sum = np.sum(volume[i - period + 1: i + 1]) - out[i] = np.sum(cv) / v_sum if v_sum > 0 else close[i] - return out + """Vectorized Volume Weighted Moving Average.""" + cv = pd.Series(close * volume) + v = pd.Series(volume) + return (cv.rolling(period).sum() / v.rolling(period).sum().replace(0, np.inf)).values def _hull_ma(close: np.ndarray, period: int = 9) -> np.ndarray: @@ -1733,10 +1654,8 @@ def compute_indicators(data: List[Dict[str, Any]]) -> Dict[str, Any]: atr14 = _atr(highs, lows, closes, 14) stoch_k, stoch_d = _stoch_rsi(closes) - # Volume SMA 20 - vol_sma = np.array([ - np.mean(vols[max(0, i-19):i+1]) for i in range(len(vols)) - ]) + # Volume SMA 20 (Vectorized v6.0) + vol_sma = _sma(vols, 20) last_close = closes[-1] last_atr = _last(atr14) or 0 @@ -1899,7 +1818,10 @@ def _blend_forecasts( scale = (last_close / first_model_est) if abs(first_model_est) > 1e-8 else 1.0 # Clip scale to [0.5, 2.0] to prevent extreme corrections on low-price coins (BUG-06) - scale = float(np.clip(scale, 0.5, 2.0)) + last_close_magnitude = math.floor(math.log10(max(abs(last_close), 1e-10))) + clip_lo = 0.3 if last_close_magnitude < -4 else 0.5 + clip_hi = 3.0 if last_close_magnitude < -4 else 2.0 + scale = float(np.clip(scale, clip_lo, clip_hi)) if abs(scale - 1.0) > 0.3: logger.warning("[Kronos] High scale correction: %.4f (first_model=%.8f, last=%.8f)", scale, first_model_est, last_close) @@ -1915,9 +1837,18 @@ def _blend_forecasts( anchor_dir = np.sign(anchor_p50[-1] - last_close) if len(anchor_p50) else 0.0 agreement = bool(model_dir == anchor_dir or model_dir == 0 or anchor_dir == 0) + # B-2: Advanced weighting sensitive to RSI extremes (v6.0) + rsi_val = indicators["rsi"].get("value") or 50.0 + rsi_extreme = abs(rsi_val - 50.0) > 20.0 # <30 or >70 + band_width_pct = abs(model_p90[-1] - model_p10[-1]) / last_close if last_close else 0.0 - base_weight = 0.68 if agreement else 0.52 - model_weight = _clamp(base_weight - max(0.0, band_width_pct - 0.08) * 1.8, 0.35, 0.82) + + # If RSI is extreme, favor the AI model which better handles mean-reversion + base_weight = 0.72 if agreement else 0.55 + if rsi_extreme: + base_weight += 0.08 + + model_weight = _clamp(base_weight - max(0.0, band_width_pct - 0.08) * 2.0, 0.30, 0.88) anchor_weight = 1.0 - model_weight blend_p10 = model_p10 * model_weight + anchor_p10 * anchor_weight @@ -1925,12 +1856,27 @@ def _blend_forecasts( blend_p90 = model_p90 * model_weight + anchor_p90 * anchor_weight bias_pct = abs((scale - 1.0) * 100.0) - confidence = 58.0 - confidence += 12.0 if agreement else -8.0 - confidence += 8.0 if indicators["trend"].get("ema_bullish_stack") else 0.0 - confidence -= min(20.0, band_width_pct * 140.0) - confidence -= min(15.0, bias_pct * 0.45) - confidence = _clamp(confidence, 12.0, 92.0) + + # Refined confidence score + confidence = 60.0 + confidence += 15.0 if agreement else -10.0 + + # Trend alignment (Bull/Bear stack) + if indicators["trend"].get("ema_bullish_stack") and model_dir > 0: + confidence += 10.0 + elif not indicators["trend"].get("ema_bullish_stack") and model_dir < 0: + # Bearish stack + down forecast + confidence += 5.0 + + # Penalty for high uncertainty (wide bands) + confidence -= min(25.0, band_width_pct * 150.0) + # Penalty for extreme bias/scaling corrections + confidence -= min(15.0, bias_pct * 0.5) + # Penalty for low volume relative to average + if not indicators["volume"].get("above_avg"): + confidence -= 5.0 + + confidence = _clamp(confidence, 10.0, 95.0) return { "p10": blend_p10, @@ -2093,7 +2039,16 @@ def _calc_price_levels(data: List[Dict[str, Any]], atr: float) -> Dict[str, Any] closes = np.array([float(d["close"]) for d in data]) highs = np.array([float(d["high"]) for d in data]) lows = np.array([float(d["low"]) for d in data]) - last_c = closes[-1] + + # Use previous COMPLETED candle for pivot calculation (BUG-P1-10) + if len(highs) >= 2: + last_h = float(highs[-2]) + last_l = float(lows[-2]) + last_c = float(closes[-2]) + else: + last_h, last_l, last_c = float(highs[-1]), float(lows[-1]), float(closes[-1]) + + pivots = _calc_pivot_points(last_h, last_l, last_c) # 1. Detect Pivot Highs and Lows (window=3) pivots_h = [] @@ -2267,7 +2222,7 @@ def _build_setups(bias: str, last_close: float, atr: float, levels: Dict[str, An )) elif bias == "bearish": - # (Similar logic for short positions) + # 1. Conservative (Thận trọng - Chờ hồi) sl_c = kr + atr * 0.3 tp_c = ns setups.append(TradeSetup( @@ -2417,8 +2372,10 @@ def _build_trade_analysis( last_close = closes[-1] def _lv(arr): + if isinstance(arr, pd.Series): + arr = arr.values v = arr[-1] if len(arr) else float('nan') - return None if (v is None or math.isnan(v)) else round(float(v), 2) + return None if (v is None or (isinstance(v, float) and math.isnan(v))) else round(float(v), 2) # ── Compute all oscillators ── rsi14 = _rsi(closes, 14) @@ -2438,7 +2395,11 @@ def _build_trade_analysis( def _add_osc(label, val, action_name, **kw): nonlocal osc_buy, osc_sell, osc_neutral - v = _lv(val) if isinstance(val, np.ndarray) else (round(float(val), 2) if val is not None else None) + # Handle scalar, Series or ndarray + if isinstance(val, (np.ndarray, pd.Series, list)): + v = _lv(val) + else: + v = round(float(val), 2) if val is not None else None act = _osc_action(action_name, v if v is not None else 0, **kw) osc_data.append({"name": label, "value": v, "action": act}) if act == "Mua": osc_buy += 1 @@ -2503,27 +2464,36 @@ def _build_trade_analysis( else: ma_signal = "Trung lập" - # ── Summary (Total) ── + # Summary (Total) ── total_buy = osc_buy + ma_buy total_sell = osc_sell + ma_sell total_neutral = osc_neutral + ma_neutral - if total_buy > total_sell + 6: - total_signal = "Mua mạnh" - elif total_buy > total_sell + 3: - total_signal = "Mua" - elif total_sell > total_buy + 6: - total_signal = "Bán mạnh" + # Derive bias from signal + if total_buy > total_sell + 3: + summary_bias = "bullish" elif total_sell > total_buy + 3: + summary_bias = "bearish" + else: + summary_bias = "neutral" + + # B-8: Integrated Summary Signals (v6.0) + # Combine TV style with ensemble confidence + if total_buy > total_sell + 6 and confidence > 65: + total_signal = "Mua mạnh (Cực độ)" + elif total_buy > total_sell + 3 and confidence > 55: + total_signal = "Mua" + elif total_sell > total_buy + 6 and confidence > 65: + total_signal = "Bán mạnh (Cực độ)" + elif total_sell > total_buy + 3 and confidence > 55: total_signal = "Bán" else: - total_signal = "Trung lập" + total_signal = "Trung lập (Thận trọng)" - # ── Pivot Points ── - # Use last completed candle for pivot calculation - last_h = float(highs[-1]) - last_l = float(lows[-1]) - last_c = float(closes[-1]) + # B-4: Use last COMPLETED candle for pivot calculation to avoid flickering + last_h = float(highs[-2]) if len(highs) > 1 else float(highs[-1]) + last_l = float(lows[-2]) if len(lows) > 1 else float(lows[-1]) + last_c = float(closes[-2]) if len(closes) > 1 else float(closes[-1]) pivots = _calc_pivot_points(last_h, last_l, last_c) return { @@ -2531,6 +2501,7 @@ def _build_trade_analysis( "summary": { "sell": total_sell, "neutral": total_neutral, "buy": total_buy, "signal": total_signal, + "bias": summary_bias, }, "oscillators": { "sell": osc_sell, "neutral": osc_neutral, "buy": osc_buy, @@ -2546,7 +2517,89 @@ def _build_trade_analysis( } +START_TIME = time.time() + +async def _background_cleanup(): + """Evict expired entries from TTL caches periodically (v6.0: Aggressive).""" + while True: + await asyncio.sleep(120) # Every 2 minutes + try: + h = historical_cache.evict_expired() + f = forecast_cache.evict_expired() + t = ticker_cache.evict_expired() + persistent_cache.evict() + logger.info("[cleanup] Evicted h=%d f=%d t=%d", h, f, t) + except Exception as ex: + logger.warning("[cleanup] Error: %s", ex) + +async def _periodic_health_check(): + """Check circuit breakers and log source health every 2 minutes.""" + while True: + await asyncio.sleep(120) + for name, cb in source_breakers.items(): + if cb.state == "OPEN": + logger.warning("[health] CB OPEN: %s (failures=%d)", name, cb.failures) + +def _clear_ip_limits(): + """Remove stale IP rate-limit entries older than 60 seconds.""" + now = time.time() + stale = [ip for ip, ts_list in IP_LIMITS.items() if not any(now - t < 60 for t in ts_list)] + for ip in stale: + del IP_LIMITS[ip] + if stale: + logger.debug("[ip-cleanup] Removed %d stale IPs", len(stale)) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup logic + logger.info("Starting Kronos AI Backend v6.0...") + + # Async background tasks + asyncio.create_task(persistent_cache.start_writer()) # P0: Async Persistence + asyncio.create_task(ws_manager.heartbeat()) + asyncio.create_task(_background_cleanup()) + asyncio.create_task(_periodic_health_check()) + asyncio.create_task(_prefetch_popular_symbols()) # D-3: Prefetcher + + # F-2: Rate limit cleanup task + async def _ip_cleanup_loop(): + while True: + await asyncio.sleep(300) # BUG-P1-07: reduced from 3600 to 300 + _clear_ip_limits() + asyncio.create_task(_ip_cleanup_loop()) + + # Quick source reachability check (non-blocking) + asyncio.create_task(_source_selftest()) + + if PRELOAD_KRONOS and KRONOS_AVAILABLE: + asyncio.create_task(_warmup_kronos()) + elif not KRONOS_AVAILABLE: + STARTUP_STATE["kronos"]["last_error"] = "Kronos source import failed" + + yield + # Shutdown logic + logger.info("Shutting down AI Trading Chart API...") + # F-5: Graceful termination of background tasks + await GlobalHTTPClient.close() + logger.info("Graceful shutdown completed.") + + +# ────────────────────────────────────────────────────────────────────────────── +# FastAPI App Instance (v6.0) +# ────────────────────────────────────────────────────────────────────────────── +app = FastAPI( + title="AI Trading Chart API", + version="6.0.0", + description="OHLCV data, hybrid AI forecasts, technical indicators, and real-time WebSocket prices", + lifespan=lifespan, +) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], allow_credentials=True, + allow_methods=["*"], allow_headers=["*"], +) # ────────────────────────────────────────────────────────────────────────────── @@ -2612,7 +2665,12 @@ class KronosForecaster: def __init__(self) -> None: self._predictor: Optional[Any] = None self._loaded = False - self._lock = asyncio.Lock() + self._lock: Optional[asyncio.Lock] = None + + async def _get_lock(self) -> asyncio.Lock: + if self._lock is None: + self._lock = asyncio.Lock() + return self._lock @property def is_ready(self) -> bool: @@ -2631,7 +2689,8 @@ class KronosForecaster: async def _lazy_load(self) -> None: if self._loaded: return - async with self._lock: + lock = await self._get_lock() + async with lock: if self._loaded: return if not KRONOS_AVAILABLE: @@ -2732,6 +2791,11 @@ class KronosForecaster: forecaster = KronosForecaster() + +# MODULE: Analysis Engine v2.0 (Relocated and Activated) +# Legacy placeholders removed to avoid duplication with logic at line 1884. + + # ────────────────────────────────────────────────────────────────────────────── # WebSocket connection manager # ────────────────────────────────────────────────────────────────────────────── @@ -2749,12 +2813,21 @@ class ConnectionManager: logger.info("[WS] disconnect: %s (total=%d)", symbol, len(self.active[symbol])) async def broadcast(self, symbol: str, data: Dict[str, Any]): + """Reliable broadcast with automatic stale connection cleanup (P0).""" + active_list = self.active.get(symbol, []) + if not active_list: + return + dead = [] - for ws in list(self.active.get(symbol, [])): + for ws in list(active_list): try: - await ws.send_json(data) + if ws.client_state == WebSocketState.CONNECTED: + await ws.send_json(data) + else: + dead.append(ws) except Exception: dead.append(ws) + for ws in dead: self.disconnect(ws, symbol) @@ -2773,7 +2846,48 @@ class ConnectionManager: ws_manager = ConnectionManager() +@app.websocket("/ws/price/{symbol}") +async def websocket_price(websocket: WebSocket, symbol: str): + """ + B-6: Real-time price streaming via WebSocket. + Fixes the 404/500 errors in chrome console. + """ + symbol = _get_canonical_symbol(symbol) + if symbol not in SYMBOLS: + await websocket.close(code=1008, reason=f"Unknown symbol: {symbol}") + return + + try: + await ws_manager.connect(websocket, symbol) + while True: + try: + # 1. State check + if websocket.client_state != WebSocketState.CONNECTED: + break + # 2. Fetch fresh price + ticker = await get_ticker(symbol) + + # 3. Final state check before send + if websocket.client_state == WebSocketState.CONNECTED: + await websocket.send_json({ + "type": "price", + "symbol": symbol, + "price": ticker.get("price"), + "change_pct": ticker.get("change_pct", 0), + "ts": int(time.time()) + }) + except (WebSocketDisconnect, RuntimeError): + break + except Exception as ex: + logger.debug("[WS] ticker fetch failed for %s: %s", symbol, ex) + + interval = 5 if SYMBOLS[symbol].category == "Crypto" else 30 + await asyncio.sleep(interval) + except Exception as ex: + logger.error("[WS] handler error for %s: %s", symbol, ex) + finally: + ws_manager.disconnect(websocket, symbol) # ────────────────────────────────────────────────────────────────────────────── # Architecture v5: Security & Guardrails (Module F) @@ -2785,7 +2899,13 @@ ADMIN_TOKEN = "kronos_v5_secret_admin" # Hardcoded as requested IP_LIMITS: Dict[str, List[float]] = defaultdict(list) def rate_limit_guard(request: Request): - """F-2: Simple IP-based rate limiting logic.""" + """ + F-2: Simple IP-based rate limiting logic. + BUG-P1-11: Whitelist health and metrics from rate limiting. + """ + if request.url.path in ["/api/health", "/api/metrics", "/api/ping"]: + return False + ip = request.client.host if request.client else "unknown" now = time.time() IP_LIMITS[ip] = [ts for ts in IP_LIMITS[ip] if now - ts < 60] @@ -2814,79 +2934,37 @@ class WatchlistRequest(BaseModel): symbols: List[str] -app = FastAPI( - title="AI Trading Chart API", - version="5.0.0", - description="OHLCV data, hybrid AI forecasts, technical indicators, and real-time WebSocket prices", -) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], allow_credentials=True, - allow_methods=["*"], allow_headers=["*"], -) - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup logic - logger.info("Starting Kronos AI Backend v5.0...") - - # Async background tasks - asyncio.create_task(ws_manager.heartbeat()) - asyncio.create_task(_background_cleanup()) - asyncio.create_task(_periodic_health_check()) - asyncio.create_task(_prefetch_popular_symbols()) # D-3: Prefetcher - - # Quick source reachability check (non-blocking) - asyncio.create_task(_source_selftest()) - - if PRELOAD_KRONOS and KRONOS_AVAILABLE: - asyncio.create_task(_warmup_kronos()) - elif not KRONOS_AVAILABLE: - STARTUP_STATE["kronos"]["last_error"] = "Kronos source import failed" - - yield - # Shutdown logic - logger.info("Shutting down AI Trading Chart API...") - # F-5: Graceful termination of background tasks - await GlobalHTTPClient.close() - logger.info("Graceful shutdown completed.") - -async def _background_cleanup(): - """Periodic task to evict expired cache entries (BUG-05).""" - while True: - await asyncio.sleep(300) # Every 5 mins - evicted = historical_cache.evict_expired() - evicted += forecast_cache.evict_expired() - evicted += ticker_cache.evict_expired() - if evicted > 0: - logger.info("[Cache] Evicted %d expired entries", evicted) - -async def _periodic_health_check(): - """Re-check source health every 30 minutes (BUG-16).""" - while True: - await asyncio.sleep(1800) - await _source_selftest() -# Set app lifespan after definition -app.router.lifespan_context = lifespan +# BUG-P0-01: Removed duplicate v5 app instances and buggy lifespan assignment +# Application v6.0 is defined earlier at line 2532 @app.middleware("http") async def rate_limit_middleware(request: Request, call_next): + # BUG-P1-11: Whitelist health endpoints + if request.url.path in ["/api/health", "/api/metrics"]: + return await call_next(request) if rate_limit_guard(request): return JSONResponse(status_code=429, content={"detail": "Too many requests"}) return await call_next(request) +RECENT_SYMBOLS: List[str] = [] + async def _prefetch_popular_symbols(): - """D-3: Prefetch data for major symbols to reduce first-load latency.""" - popular = ["XAUUSD", "BTCUSD", "ETHUSD", "DXY", "SP500"] + """D-3: Prefetch data for major symbols and active session symbols (P2).""" + static_popular = ["XAUUSD", "BTCUSD", "ETHUSD", "DXY", "SP500"] while True: - logger.info("[Prefetch] Refreshing popular symbols...") - for sym in popular: + # Merge static favorites with recently viewed symbols + targets = list(dict.fromkeys(RECENT_SYMBOLS + static_popular))[:10] + + logger.info("[Prefetch] Refreshing symbols: %s", targets) + for sym in targets: try: - await fetch_historical(sym, "1h", 200) - await asyncio.sleep(1) # Gentle throttling + # Fetch 1h and 1d to warm up both indicator contexts + await fetch_historical(sym, "1h", 300) + await asyncio.sleep(0.5) + await fetch_historical(sym, "1d", 200) + await asyncio.sleep(1.0) # Gentle throttling except Exception: pass await asyncio.sleep(300) # Every 5 mins @@ -2938,37 +3016,94 @@ async def _warmup_kronos() -> None: # ── Symbol / Interval listing ───────────────────────────────────────────────── +_SYMBOLS_CACHE: Optional[Dict[str, Any]] = None + @app.get("/api/symbols") async def list_symbols( category: Optional[str] = Query(None, description="Filter by category"), ) -> Dict[str, Any]: - results = list(SYMBOLS.values()) + global _SYMBOLS_CACHE + + if _SYMBOLS_CACHE is None: + all_results = list(SYMBOLS.values()) + grouped: Dict[str, List[Dict]] = {} + for s in all_results: + entry = { + "symbol": s.symbol, + "label": s.label, + "label_en": s.label_en, + "category": s.category, + "description": s.description, + "sources": list(s.mappings.keys()), + } + grouped.setdefault(s.category, []).append(entry) + + _SYMBOLS_CACHE = { + "total": len(all_results), + "categories": sorted(grouped.keys()), + "symbols": [ { "symbol": s.symbol, "label": s.label, "label_en": s.label_en, + "category": s.category, "sources": list(s.mappings.keys()) } + for s in all_results ], + "symbols_by_category": grouped, + "supported_intervals": INTERVAL_ORDER + } + if category: - results = [s for s in results if s.category.lower() == category.lower()] - - # Group by category for structured response - grouped: Dict[str, List[Dict]] = {} - for s in results: - entry = { - "symbol": s.symbol, - "label": s.label, - "label_en": s.label_en, - "category": s.category, - "description": s.description, - "sources": list(s.mappings.keys()), + cat_symbols = _SYMBOLS_CACHE["symbols_by_category"].get(category, []) + return { + "total": len(cat_symbols), + "categories": _SYMBOLS_CACHE["categories"], + "symbols": cat_symbols, + "symbols_by_category": { category: cat_symbols }, + "supported_intervals": INTERVAL_ORDER } - grouped.setdefault(s.category, []).append(entry) + return _SYMBOLS_CACHE + + +# ── Market Peers ────────────────────────────────────────────────────────────── +@app.get("/api/market-peers") +async def get_market_peers(symbol: str = Query("BTCUSD")) -> Dict[str, Any]: + # Fix: Ensure symbol is canonical to avoid category mismatches + symbol = _get_canonical_symbol(symbol.upper()) + + if symbol not in SYMBOLS: + # Fallback to Crypto if unknown + return await crypto_market(top=10) + + cfg = SYMBOLS[symbol] + category = cfg.category + + # Find all peers in the same category + peers_list = [s for s in SYMBOLS.values() if s.category == category and s.symbol != symbol] + + # If too few peers in category, mix with others + if len(peers_list) < 3: + all_others = [s for s in SYMBOLS.values() if s.symbol != symbol] + peers_list.extend(all_others[:5]) + + # Format result with actual price data + result_peers = [] + # Batch fetch ticker data for peers + for p in peers_list[:12]: + try: + ticker = await fetch_ticker(p.symbol) + result_peers.append({ + "symbol": p.symbol, + "label": p.label, + "category": p.category, + "price": ticker.get("price", 0), + "change_24h": ticker.get("change_pct", 0) + }) + except: + result_peers.append({ + "symbol": p.symbol, "label": p.label, "category": p.category, + "price": 0, "change_24h": 0 + }) + return { - "total": len(results), - "supported_intervals": INTERVAL_ORDER, - "categories": sorted(grouped.keys()), - "symbols_by_category": grouped, - "symbols": [ - {"symbol": s.symbol, "label": s.label, "label_en": s.label_en, - "category": s.category, "sources": list(s.mappings.keys())} - for s in results - ], + "category": category, + "peers": result_peers } @@ -3044,6 +3179,111 @@ async def get_indicators( } +# ── Technical Analysis Engine ───────────────────────────────────────────────── +@app.get("/api/analysis/{symbol}") +async def get_analysis( + symbol: str, + interval: str = Query("1h"), +) -> Dict[str, Any]: + """ + A-5: Direct access to the comprehensive Analysis Engine. + E-2: Integrated Multi-timeframe (MTF) analysis. + """ + symbol = _get_canonical_symbol(symbol) + if symbol not in SYMBOLS: + raise HTTPException(404, f"Unknown symbol: {symbol}") + + # Fetch main context + data, source = await fetch_historical(symbol, interval, 500) + if len(data) < 50: + raise HTTPException(422, "Insufficient data for full analysis") + + # Fetch higher timeframe context (MTF) + htf_interval = "1d" if interval in ["1h", "2h", "4h"] else "1h" if interval in ["5m", "15m"] else None + htf_bias = "neutral" + if htf_interval: + try: + htf_data, _ = await fetch_historical(symbol, htf_interval, 200) + htf_inds = compute_indicators(htf_data) + htf_bias = "bullish" if htf_inds["trend"].get("above_ema200") else "bearish" + except Exception: + pass + + # Compute Indicators + indicators = compute_indicators(data) + + # We need a mock forecast return if no forecast is available + forecast_ret = 0.0 + confidence = 50.0 + try: + # Try to get from cache to avoid heavy re-computation + f_prefix = _cache_prefix(symbol, interval) + f_cache = forecast_cache.get(f"forecast_{f_prefix}10") + if f_cache: + forecast_ret = _pct(f_cache["forecast"][-1]["p50"], f_cache["last_close"]) + confidence = f_cache["ensemble"]["confidence"] + except Exception: + pass + + # Build comprehensive analysis + analysis = _build_trade_analysis( + symbol=symbol, + interval=interval, + data=data, + indicators=indicators, + forecast_rows=[], # Optional here + confidence=confidence, + source=source, + ) + + # Inject MTF into analysis + analysis["multi_timeframe"] = { + "htf_interval": htf_interval, + "htf_bias": htf_bias, + "alignment": bool((analysis["summary"]["bias"] == "bullish" and htf_bias == "bullish") or + (analysis["summary"]["bias"] == "bearish" and htf_bias == "bearish")) + } + + return { + "symbol": symbol, + "interval": interval, + "timestamp": int(time.time()), + "analysis": analysis, + "verdict": await get_gemini_verdict(symbol, analysis, forecast_ret), + "indicators_snapshot": indicators if Query(False) else None # Save bandwidth + } + + +async def get_gemini_verdict(symbol: str, analysis: Dict[str, Any], forecast_pct: float = 0.0) -> str: + """ + Get a strict 4-option verdict from Gemini based on technical analysis + AI Forecast. + Options: 'Mua ngay', 'Bán ngay', 'Nên đợi mua giá thấp hơn', 'Nên đợi bán giá cao hơn' + """ + summary = analysis.get("summary", {}) + bias = summary.get("bias", "neutral") + signal = summary.get("signal", "Trung lập") + + # Translate forecast to readable text + forecast_text = "Tăng giá" if forecast_pct > 0.5 else "Giảm giá" if forecast_pct < -0.5 else "Đi ngang" + + prompt = f""" + Dựa trên phân tích kỹ thuật và dự báo AI cho mã {symbol}: + - Xu hướng kỹ thuật: {bias} ({signal}) + - Tín hiệu Oscillators: {analysis['oscillators']['signal']} + - Tín hiệu Moving Averages: {analysis['moving_averages']['signal']} + - Dự báo AI (24h tới): {forecast_text} (biến động {forecast_pct:.2f}%) + + Hãy đưa ra kết luận DUY NHẤT trong 4 lựa chọn sau: + 1. Mua ngay + 2. Bán ngay + 3. Nên đợi mua giá thấp hơn + 4. Nên đợi bán giá cao hơn + + CHỈ TRẢ VỀ CỤM TỪ KẾT LUẬN, KHÔNG GIẢI THÍCH. + """ + return await fetch_gemini_analysis(prompt) + + # ── Real-time ticker ────────────────────────────────────────────────────────── @app.get("/api/ticker/{symbol}") async def get_ticker(symbol: str) -> Dict[str, Any]: @@ -3084,10 +3324,20 @@ async def get_forecast( prefix = _cache_prefix(symbol, interval) cache_key = f"forecast_{prefix}{horizon}" - cached = forecast_cache.get(cache_key) + + # L1: RAM Cache + cached = forecast_cache.get(cache_key) if cached is not None: return cached + # L2: Persistent SQLite Cache (A-4) + p_cached = persistent_cache.get(cache_key) + if p_cached is not None: + p_cached["from_persistent_cache"] = True + # Backfill L1 + forecast_cache.set(cache_key, p_cached, ttl_seconds=forecast_ttl(interval)) + return p_cached + data_list, source = await fetch_historical(symbol, interval, 1500) if not KRONOS_AVAILABLE: # Return graceful empty forecast so UI doesn't break @@ -3173,6 +3423,7 @@ async def get_forecast( "horizon": horizon, "last_close": last_close, "forecast": forecast_rows, + "from_persistent_cache": False, "model": { "name": model_output.get("model_name", "Kronos-base"), "context_length": int(model_output.get("context_length", context_len)), @@ -3193,7 +3444,10 @@ async def get_forecast( "analysis": analysis, } + # L1: RAM forecast_cache.set(cache_key, response, ttl_seconds=forecast_ttl(interval)) + # L2: SQLite (long TTL = forecast_ttl * 4) + persistent_cache.set(cache_key, response, ttl=forecast_ttl(interval) * 4) return response @@ -3204,56 +3458,6 @@ async def get_market_status() -> Dict[str, Any]: "utc_time": datetime.now(timezone.utc).isoformat(), "markets": market_status_now(), } - - -# ── Crypto market overview (CoinGecko) ─────────────────────────────────────── -@app.get("/api/crypto/market") -async def crypto_market(top: int = Query(20, ge=5, le=100)) -> Dict[str, Any]: - cached = ticker_cache.get(f"cg_market:{top}") - if cached: - return cached - await _rate_limit("coingecko") - async with httpx.AsyncClient(timeout=15) as c: - r = await c.get( - "https://api.coingecko.com/api/v3/coins/markets", - params={"vs_currency": "usd", "order": "market_cap_desc", - "per_page": top, "page": 1, "sparkline": False}, - ) - if r.status_code != 200: - raise HTTPException(502, "CoinGecko markets unavailable") - coins = r.json() - result = { - "count": len(coins), - "coins": [{ - "rank": c["market_cap_rank"], - "id": c["id"], - "symbol": _get_canonical_symbol(c["symbol"]), - "name": c["name"], - "price": c["current_price"], - "change_24h": c["price_change_percentage_24h"], - "market_cap": c["market_cap"], - "volume_24h": c["total_volume"], - "high_24h": c["high_24h"], - "low_24h": c["low_24h"], - } for c in coins], - "fetched_at": int(time.time()), - } - ticker_cache.set(f"cg_market:{top}", result, ttl_seconds=60) - return result - - -@app.get("/api/market-peers") -async def get_market_peers(symbol: str = Query("BTCUSD")) -> Dict[str, Any]: - symbol = symbol.upper() - if symbol not in SYMBOLS: - # Fallback to general market if unknown - return await crypto_market(top=10) - - cfg = SYMBOLS[symbol] - category = cfg.category - - # 1. Find all peers in the same category - peers = [s for s in SYMBOLS.values() if s.category == category and s.symbol != symbol] # 2. Limit to top 10 (or first 10 in registry) peers = peers[:10] @@ -3295,6 +3499,13 @@ async def switch_symbol_interval(body: SwitchRequest) -> Dict[str, Any]: prefix = _cache_prefix(symbol, interval) h_cleared = historical_cache.delete_by_prefix(f"hist_{prefix}") f_cleared = forecast_cache.delete_by_prefix(f"forecast_{prefix}") + + # P2: Track recent symbols for pre-fetching + global RECENT_SYMBOLS + if symbol not in RECENT_SYMBOLS: + RECENT_SYMBOLS.insert(0, symbol) + RECENT_SYMBOLS = RECENT_SYMBOLS[:5] # Keep top 5 + logger.info("[switch] %s %s → hist=%d forecast=%d", symbol, interval, h_cleared, f_cleared) return { @@ -3363,6 +3574,37 @@ async def health_check() -> Dict[str, Any]: # ─── EXTRA API ENDPOINTS (v5.0) ──────────────────────────────────────────────── +async def fetch_gemini_analysis(prompt: str) -> str: + """Fetch AI analysis from Google Gemini.""" + if not settings.gemini_api_key: + return "Gemini API key is not configured." + + url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent?key={settings.gemini_api_key}" + headers = {"Content-Type": "application/json"} + payload = { + "contents": [{"parts": [{"text": prompt}]}] + } + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, headers=headers, json=payload) + if resp.status_code != 200: + logger.error("[Gemini] Error: %d - %s", resp.status_code, resp.text) + return "Phân tích AI không khả dụng" + + data = resp.json() + candidates = data.get("candidates", []) + if candidates and candidates[0].get("content", {}).get("parts"): + text = candidates[0]["content"]["parts"][0].get("text", "").strip() + # Ensure we only return the verdict text if it's a verdict call + # We'll rely on the prompt to enforce this, but can sanitize here + return text + return "Không có phản hồi từ AI" + except Exception as ex: + logger.error("[Gemini] Exception: %s", ex) + return "Lỗi phân tích AI" + + @app.get("/api/metrics") async def get_metrics(request: Request): """Export Prometheus-ready metrics (latencies, cache hits, CB states)."""