Add configurable data providers and localize frontend UI
This commit is contained in:
@@ -1,29 +1,77 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""Centralized data source configuration and fallback ordering."""
|
||||||
Centralized Data Source Configuration
|
|
||||||
|
|
||||||
Auto-detects and manages data source based on available API keys.
|
|
||||||
Priority: FINNHUB_API_KEY > FINANCIAL_DATASETS_API_KEY
|
|
||||||
"""
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
DataSource = Literal["finnhub", "financial_datasets"]
|
DataSource = Literal["finnhub", "financial_datasets", "yfinance", "local_csv"]
|
||||||
|
_KNOWN_SOURCES: tuple[DataSource, ...] = (
|
||||||
|
"finnhub",
|
||||||
|
"financial_datasets",
|
||||||
|
"yfinance",
|
||||||
|
"local_csv",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DataSourceConfig:
|
class DataSourceConfig:
|
||||||
"""Immutable data source configuration"""
|
"""Resolved data source configuration."""
|
||||||
|
|
||||||
source: DataSource
|
source: DataSource
|
||||||
api_key: str
|
api_key: str
|
||||||
|
sources: list[DataSource]
|
||||||
|
|
||||||
|
|
||||||
# Module-level cache for the resolved configuration
|
# Module-level cache for the resolved configuration
|
||||||
_config_cache: Optional[DataSourceConfig] = None
|
_config_cache: Optional[DataSourceConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_enabled_sources() -> list[DataSource]:
|
||||||
|
"""Parse optional enabled source allowlist from the environment."""
|
||||||
|
raw_value = os.getenv("ENABLED_DATA_SOURCES", "").strip().lower()
|
||||||
|
if not raw_value:
|
||||||
|
return []
|
||||||
|
|
||||||
|
enabled: list[DataSource] = []
|
||||||
|
for item in raw_value.split(","):
|
||||||
|
candidate = item.strip()
|
||||||
|
if not candidate or candidate not in _KNOWN_SOURCES:
|
||||||
|
continue
|
||||||
|
if candidate not in enabled:
|
||||||
|
enabled.append(candidate)
|
||||||
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_sources() -> list[DataSource]:
|
||||||
|
"""Resolve source preference and available fallbacks."""
|
||||||
|
preferred = os.getenv("FIN_DATA_SOURCE", "").strip().lower()
|
||||||
|
finnhub_key = os.getenv("FINNHUB_API_KEY", "").strip()
|
||||||
|
fd_key = os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip()
|
||||||
|
enabled_sources = _parse_enabled_sources()
|
||||||
|
wants_yfinance = preferred == "yfinance" or "yfinance" in enabled_sources
|
||||||
|
|
||||||
|
available: list[DataSource] = []
|
||||||
|
if finnhub_key:
|
||||||
|
available.append("finnhub")
|
||||||
|
if fd_key:
|
||||||
|
available.append("financial_datasets")
|
||||||
|
if wants_yfinance:
|
||||||
|
available.append("yfinance")
|
||||||
|
available.append("local_csv")
|
||||||
|
|
||||||
|
if enabled_sources:
|
||||||
|
filtered = [source for source in enabled_sources if source in available]
|
||||||
|
if filtered:
|
||||||
|
available = filtered
|
||||||
|
|
||||||
|
if preferred in available:
|
||||||
|
ordered = [preferred]
|
||||||
|
ordered.extend(source for source in available if source != preferred)
|
||||||
|
return ordered
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
def _resolve_config() -> DataSourceConfig:
|
def _resolve_config() -> DataSourceConfig:
|
||||||
"""
|
"""
|
||||||
Resolve data source configuration based on available API keys.
|
Resolve data source configuration based on available API keys.
|
||||||
@@ -33,21 +81,22 @@ def _resolve_config() -> DataSourceConfig:
|
|||||||
2. FINANCIAL_DATASETS_API_KEY (if set)
|
2. FINANCIAL_DATASETS_API_KEY (if set)
|
||||||
3. Raises error if neither is available
|
3. Raises error if neither is available
|
||||||
"""
|
"""
|
||||||
# Check for Finnhub API key first (higher priority)
|
sources = _ordered_sources()
|
||||||
finnhub_key = os.getenv("FINNHUB_API_KEY")
|
if "finnhub" in sources:
|
||||||
if finnhub_key:
|
return DataSourceConfig(
|
||||||
return DataSourceConfig(source="finnhub", api_key=finnhub_key)
|
source="finnhub",
|
||||||
|
api_key=os.getenv("FINNHUB_API_KEY", "").strip(),
|
||||||
# Fallback to Financial Datasets API
|
sources=sources,
|
||||||
fd_key = os.getenv("FINANCIAL_DATASETS_API_KEY")
|
)
|
||||||
if fd_key:
|
if "financial_datasets" in sources:
|
||||||
return DataSourceConfig(source="financial_datasets", api_key=fd_key)
|
return DataSourceConfig(
|
||||||
|
source="financial_datasets",
|
||||||
# No API key available
|
api_key=os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip(),
|
||||||
raise ValueError(
|
sources=sources,
|
||||||
"No API key found. Please set either FINNHUB_API_KEY or "
|
)
|
||||||
"FINANCIAL_DATASETS_API_KEY in your .env file.",
|
if "yfinance" in sources:
|
||||||
)
|
return DataSourceConfig(source="yfinance", api_key="", sources=sources)
|
||||||
|
return DataSourceConfig(source="local_csv", api_key="", sources=sources)
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> DataSourceConfig:
|
def get_config() -> DataSourceConfig:
|
||||||
@@ -71,6 +120,11 @@ def get_data_source() -> DataSource:
|
|||||||
return get_config().source
|
return get_config().source
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_sources() -> list[DataSource]:
|
||||||
|
"""Get preferred source ordering including fallbacks."""
|
||||||
|
return get_config().sources
|
||||||
|
|
||||||
|
|
||||||
def get_api_key() -> str:
|
def get_api_key() -> str:
|
||||||
"""Get the API key for the configured data source."""
|
"""Get the API key for the configured data source."""
|
||||||
return get_config().api_key
|
return get_config().api_key
|
||||||
|
|||||||
@@ -1,22 +1,55 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""Environment config helpers with light validation and normalization."""
|
||||||
Simple environment config helpers
|
|
||||||
"""
|
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
|
||||||
|
PROVIDER_ALIASES = {
|
||||||
|
"openai_compatible": "OPENAI",
|
||||||
|
"openai_compat": "OPENAI",
|
||||||
|
"claude": "ANTHROPIC",
|
||||||
|
"google": "GEMINI",
|
||||||
|
"vertex": "GEMINI",
|
||||||
|
"vertexai": "GEMINI",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentModelConfig:
|
||||||
|
"""Resolved model config for one agent."""
|
||||||
|
|
||||||
|
model_name: str
|
||||||
|
provider: str
|
||||||
|
|
||||||
|
|
||||||
|
def _get_env_raw(key: str) -> Optional[str]:
|
||||||
|
value = os.getenv(key)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_str(key: str, default: str = "") -> str:
|
||||||
|
"""Get trimmed string from env."""
|
||||||
|
value = _get_env_raw(key)
|
||||||
|
return value if value is not None else default
|
||||||
|
|
||||||
|
|
||||||
def get_env_list(key: str, default: list = None) -> list:
|
def get_env_list(key: str, default: list = None) -> list:
|
||||||
"""Get comma-separated list from env"""
|
"""Get comma-separated list from env."""
|
||||||
value = os.getenv(key, "")
|
value = _get_env_raw(key)
|
||||||
if not value:
|
if not value:
|
||||||
return default or []
|
return default or []
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
def get_env_float(key: str, default: float = 0.0) -> float:
|
def get_env_float(key: str, default: float = 0.0) -> float:
|
||||||
"""Get float from env"""
|
"""Get float from env."""
|
||||||
value = os.getenv(key)
|
value = _get_env_raw(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
@@ -26,11 +59,45 @@ def get_env_float(key: str, default: float = 0.0) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def get_env_int(key: str, default: int = 0) -> int:
|
def get_env_int(key: str, default: int = 0) -> int:
|
||||||
"""Get int from env"""
|
"""Get int from env."""
|
||||||
value = os.getenv(key)
|
value = _get_env_raw(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_bool(key: str, default: bool = False) -> bool:
|
||||||
|
"""Parse common truthy/falsey env values."""
|
||||||
|
value = _get_env_raw(key)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.lower() not in FALSEY_ENV_VALUES
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_model_provider(provider: Optional[str]) -> str:
|
||||||
|
"""Normalize provider labels to stable uppercase names."""
|
||||||
|
if not provider:
|
||||||
|
return "OPENAI"
|
||||||
|
normalized = provider.strip().lower().replace("-", "_")
|
||||||
|
normalized = PROVIDER_ALIASES.get(normalized, normalized)
|
||||||
|
return normalized.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_model_config(agent_id: str) -> AgentModelConfig:
|
||||||
|
"""Resolve model config with agent-specific override and global fallback."""
|
||||||
|
agent_key = agent_id.upper().replace("-", "_")
|
||||||
|
model_name = get_env_str(f"AGENT_{agent_key}_MODEL_NAME")
|
||||||
|
provider = get_env_str(f"AGENT_{agent_key}_MODEL_PROVIDER")
|
||||||
|
|
||||||
|
if not model_name:
|
||||||
|
model_name = get_env_str("MODEL_NAME", "gpt-4o")
|
||||||
|
if not provider:
|
||||||
|
provider = get_env_str("MODEL_PROVIDER", "OPENAI")
|
||||||
|
|
||||||
|
return AgentModelConfig(
|
||||||
|
model_name=model_name,
|
||||||
|
provider=canonicalize_model_provider(provider),
|
||||||
|
)
|
||||||
|
|||||||
@@ -451,6 +451,7 @@ class StateSync:
|
|||||||
"leaderboard": self._state.get("leaderboard", []),
|
"leaderboard": self._state.get("leaderboard", []),
|
||||||
"portfolio": self._state.get("portfolio", {}),
|
"portfolio": self._state.get("portfolio", {}),
|
||||||
"realtime_prices": self._state.get("realtime_prices", {}),
|
"realtime_prices": self._state.get("realtime_prices", {}),
|
||||||
|
"data_sources": self._state.get("data_sources", {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
if include_dashboard:
|
if include_dashboard:
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ Historical Price Manager for backtest mode
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Dict, List, Optional
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
from backend.data.provider_utils import normalize_symbol
|
||||||
|
from backend.data.provider_router import get_provider_router
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Path to local CSV data directory
|
|
||||||
_DATA_DIR = Path(__file__).parent / "ret_data"
|
|
||||||
|
|
||||||
|
|
||||||
class HistoricalPriceManager:
|
class HistoricalPriceManager:
|
||||||
"""Provides historical prices for backtest mode"""
|
"""Provides historical prices for backtest mode"""
|
||||||
@@ -27,6 +25,7 @@ class HistoricalPriceManager:
|
|||||||
self.open_prices = {}
|
self.open_prices = {}
|
||||||
self.close_prices = {}
|
self.close_prices = {}
|
||||||
self.running = False
|
self.running = False
|
||||||
|
self._router = get_provider_router()
|
||||||
|
|
||||||
def subscribe(
|
def subscribe(
|
||||||
self,
|
self,
|
||||||
@@ -34,12 +33,14 @@ class HistoricalPriceManager:
|
|||||||
):
|
):
|
||||||
"""Subscribe to symbols"""
|
"""Subscribe to symbols"""
|
||||||
for symbol in symbols:
|
for symbol in symbols:
|
||||||
|
symbol = normalize_symbol(symbol)
|
||||||
if symbol not in self.subscribed_symbols:
|
if symbol not in self.subscribed_symbols:
|
||||||
self.subscribed_symbols.append(symbol)
|
self.subscribed_symbols.append(symbol)
|
||||||
|
|
||||||
def unsubscribe(self, symbols: List[str]):
|
def unsubscribe(self, symbols: List[str]):
|
||||||
"""Unsubscribe from symbols"""
|
"""Unsubscribe from symbols"""
|
||||||
for symbol in symbols:
|
for symbol in symbols:
|
||||||
|
symbol = normalize_symbol(symbol)
|
||||||
if symbol in self.subscribed_symbols:
|
if symbol in self.subscribed_symbols:
|
||||||
self.subscribed_symbols.remove(symbol)
|
self.subscribed_symbols.remove(symbol)
|
||||||
self._price_cache.pop(symbol, None)
|
self._price_cache.pop(symbol, None)
|
||||||
@@ -50,19 +51,9 @@ class HistoricalPriceManager:
|
|||||||
|
|
||||||
def _load_from_csv(self, symbol: str) -> Optional[pd.DataFrame]:
|
def _load_from_csv(self, symbol: str) -> Optional[pd.DataFrame]:
|
||||||
"""Load price data from local CSV file."""
|
"""Load price data from local CSV file."""
|
||||||
csv_path = _DATA_DIR / f"{symbol}.csv"
|
|
||||||
if not csv_path.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
df = pd.read_csv(csv_path)
|
df = self._router.load_local_price_frame(symbol)
|
||||||
if df.empty or "time" not in df.columns:
|
return df if not df.empty else None
|
||||||
return None
|
|
||||||
|
|
||||||
df["Date"] = pd.to_datetime(df["time"])
|
|
||||||
df.set_index("Date", inplace=True)
|
|
||||||
df.sort_index(inplace=True)
|
|
||||||
return df
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load CSV for {symbol}: {e}")
|
logger.warning(f"Failed to load CSV for {symbol}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import random
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Callable, Dict, List, Optional
|
from typing import Callable, Dict, List, Optional
|
||||||
|
from backend.data.provider_utils import normalize_symbol
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ class MockPriceManager:
|
|||||||
):
|
):
|
||||||
"""Subscribe to stock symbols"""
|
"""Subscribe to stock symbols"""
|
||||||
for symbol in symbols:
|
for symbol in symbols:
|
||||||
|
symbol = normalize_symbol(symbol)
|
||||||
if symbol not in self.subscribed_symbols:
|
if symbol not in self.subscribed_symbols:
|
||||||
self.subscribed_symbols.append(symbol)
|
self.subscribed_symbols.append(symbol)
|
||||||
|
|
||||||
@@ -90,6 +92,7 @@ class MockPriceManager:
|
|||||||
def unsubscribe(self, symbols: List[str]):
|
def unsubscribe(self, symbols: List[str]):
|
||||||
"""Unsubscribe from symbols"""
|
"""Unsubscribe from symbols"""
|
||||||
for symbol in symbols:
|
for symbol in symbols:
|
||||||
|
symbol = normalize_symbol(symbol)
|
||||||
if symbol in self.subscribed_symbols:
|
if symbol in self.subscribed_symbols:
|
||||||
self.subscribed_symbols.remove(symbol)
|
self.subscribed_symbols.remove(symbol)
|
||||||
self.base_prices.pop(symbol, None)
|
self.base_prices.pop(symbol, None)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Polling-based Price Manager - Uses Finnhub REST API
|
Polling-based Price Manager with provider-aware quote polling.
|
||||||
Supports real-time price fetching via polling
|
Supports Finnhub and yfinance for near real-time price fetching.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
@@ -9,22 +9,35 @@ import time
|
|||||||
from typing import Callable, Dict, List, Optional
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
import finnhub
|
import finnhub
|
||||||
|
import yfinance as yf
|
||||||
|
from backend.data.provider_utils import normalize_symbol
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PollingPriceManager:
|
class PollingPriceManager:
|
||||||
"""Polling-based price manager using Finnhub Quote API"""
|
"""Polling-based price manager using Finnhub or yfinance."""
|
||||||
|
|
||||||
def __init__(self, api_key: str, poll_interval: int = 30):
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
poll_interval: int = 30,
|
||||||
|
provider: str = "finnhub",
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
api_key: Finnhub API Key
|
api_key: Finnhub API Key
|
||||||
poll_interval: Polling interval in seconds (default 30s)
|
poll_interval: Polling interval in seconds (default 30s)
|
||||||
|
provider: Quote provider (`finnhub` or `yfinance`)
|
||||||
"""
|
"""
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.poll_interval = poll_interval
|
self.poll_interval = poll_interval
|
||||||
self.finnhub_client = finnhub.Client(api_key=api_key)
|
self.provider = provider
|
||||||
|
self.finnhub_client = (
|
||||||
|
finnhub.Client(api_key=api_key)
|
||||||
|
if provider == "finnhub" and api_key
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
self.subscribed_symbols: List[str] = []
|
self.subscribed_symbols: List[str] = []
|
||||||
self.latest_prices: Dict[str, float] = {}
|
self.latest_prices: Dict[str, float] = {}
|
||||||
@@ -35,12 +48,14 @@ class PollingPriceManager:
|
|||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"PollingPriceManager initialized (interval: {poll_interval}s)",
|
"PollingPriceManager initialized "
|
||||||
|
f"(provider: {provider}, interval: {poll_interval}s)",
|
||||||
)
|
)
|
||||||
|
|
||||||
def subscribe(self, symbols: List[str]):
|
def subscribe(self, symbols: List[str]):
|
||||||
"""Subscribe to stock symbols"""
|
"""Subscribe to stock symbols"""
|
||||||
for symbol in symbols:
|
for symbol in symbols:
|
||||||
|
symbol = normalize_symbol(symbol)
|
||||||
if symbol not in self.subscribed_symbols:
|
if symbol not in self.subscribed_symbols:
|
||||||
self.subscribed_symbols.append(symbol)
|
self.subscribed_symbols.append(symbol)
|
||||||
logger.info(f"Subscribed to: {symbol}")
|
logger.info(f"Subscribed to: {symbol}")
|
||||||
@@ -48,6 +63,7 @@ class PollingPriceManager:
|
|||||||
def unsubscribe(self, symbols: List[str]):
|
def unsubscribe(self, symbols: List[str]):
|
||||||
"""Unsubscribe from symbols"""
|
"""Unsubscribe from symbols"""
|
||||||
for symbol in symbols:
|
for symbol in symbols:
|
||||||
|
symbol = normalize_symbol(symbol)
|
||||||
if symbol in self.subscribed_symbols:
|
if symbol in self.subscribed_symbols:
|
||||||
self.subscribed_symbols.remove(symbol)
|
self.subscribed_symbols.remove(symbol)
|
||||||
logger.info(f"Unsubscribed: {symbol}")
|
logger.info(f"Unsubscribed: {symbol}")
|
||||||
@@ -60,7 +76,7 @@ class PollingPriceManager:
|
|||||||
"""Fetch latest prices for all subscribed stocks"""
|
"""Fetch latest prices for all subscribed stocks"""
|
||||||
for symbol in self.subscribed_symbols:
|
for symbol in self.subscribed_symbols:
|
||||||
try:
|
try:
|
||||||
quote_data = self.finnhub_client.quote(symbol)
|
quote_data = self._fetch_quote(symbol)
|
||||||
|
|
||||||
current_price = quote_data.get("c")
|
current_price = quote_data.get("c")
|
||||||
open_price = quote_data.get("o")
|
open_price = quote_data.get("o")
|
||||||
@@ -114,6 +130,67 @@ class PollingPriceManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch {symbol} price: {e}")
|
logger.error(f"Failed to fetch {symbol} price: {e}")
|
||||||
|
|
||||||
|
def _fetch_quote(self, symbol: str) -> Dict[str, float]:
|
||||||
|
"""Fetch a normalized quote payload from the configured provider."""
|
||||||
|
if self.provider == "yfinance":
|
||||||
|
return self._fetch_yfinance_quote(symbol)
|
||||||
|
if not self.finnhub_client:
|
||||||
|
raise ValueError("Finnhub API key required for finnhub polling")
|
||||||
|
return self.finnhub_client.quote(symbol)
|
||||||
|
|
||||||
|
def _fetch_yfinance_quote(self, symbol: str) -> Dict[str, float]:
|
||||||
|
"""Fetch quote data from yfinance and normalize to Finnhub-like keys."""
|
||||||
|
ticker = yf.Ticker(symbol)
|
||||||
|
fast_info = dict(getattr(ticker, "fast_info", {}) or {})
|
||||||
|
|
||||||
|
current_price = _coerce_float(
|
||||||
|
fast_info.get("lastPrice") or fast_info.get("regularMarketPrice"),
|
||||||
|
)
|
||||||
|
open_price = _coerce_float(
|
||||||
|
fast_info.get("open") or fast_info.get("regularMarketOpen"),
|
||||||
|
)
|
||||||
|
previous_close = _coerce_float(
|
||||||
|
fast_info.get("previousClose")
|
||||||
|
or fast_info.get("regularMarketPreviousClose"),
|
||||||
|
)
|
||||||
|
high_price = _coerce_float(
|
||||||
|
fast_info.get("dayHigh") or fast_info.get("regularMarketDayHigh"),
|
||||||
|
)
|
||||||
|
low_price = _coerce_float(
|
||||||
|
fast_info.get("dayLow") or fast_info.get("regularMarketDayLow"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_price is None:
|
||||||
|
history = ticker.history(period="1d", interval="1m", auto_adjust=False)
|
||||||
|
if history.empty:
|
||||||
|
raise ValueError(f"{symbol}: No yfinance quote data")
|
||||||
|
latest = history.iloc[-1]
|
||||||
|
current_price = _coerce_float(latest.get("Close"))
|
||||||
|
open_price = open_price or _coerce_float(history.iloc[0].get("Open"))
|
||||||
|
high_price = high_price or _coerce_float(history["High"].max())
|
||||||
|
low_price = low_price or _coerce_float(history["Low"].min())
|
||||||
|
|
||||||
|
if current_price is None:
|
||||||
|
raise ValueError(f"{symbol}: Invalid yfinance quote data")
|
||||||
|
|
||||||
|
effective_open = open_price or previous_close or current_price
|
||||||
|
effective_prev_close = previous_close or effective_open or current_price
|
||||||
|
change = current_price - effective_prev_close
|
||||||
|
change_percent = (
|
||||||
|
(change / effective_prev_close) * 100 if effective_prev_close else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"c": current_price,
|
||||||
|
"o": effective_open,
|
||||||
|
"h": high_price or max(current_price, effective_open),
|
||||||
|
"l": low_price or min(current_price, effective_open),
|
||||||
|
"pc": effective_prev_close,
|
||||||
|
"d": change,
|
||||||
|
"dp": change_percent,
|
||||||
|
"t": int(time.time()),
|
||||||
|
}
|
||||||
|
|
||||||
def _polling_loop(self):
|
def _polling_loop(self):
|
||||||
"""Main polling loop"""
|
"""Main polling loop"""
|
||||||
logger.info(f"Price polling started (interval: {self.poll_interval}s)")
|
logger.info(f"Price polling started (interval: {self.poll_interval}s)")
|
||||||
@@ -173,3 +250,12 @@ class PollingPriceManager:
|
|||||||
"""Reset open prices for new trading day"""
|
"""Reset open prices for new trading day"""
|
||||||
self.open_prices.clear()
|
self.open_prices.clear()
|
||||||
logger.info("Open prices reset")
|
logger.info("Open prices reset")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_float(value) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|||||||
870
backend/data/provider_router.py
Normal file
870
backend/data/provider_router.py
Normal file
@@ -0,0 +1,870 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Unified data provider router with fallback support."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import finnhub
|
||||||
|
import pandas as pd
|
||||||
|
import yfinance as yf
|
||||||
|
|
||||||
|
from backend.config.data_config import DataSource, get_data_sources
|
||||||
|
from backend.data.schema import (
|
||||||
|
CompanyFactsResponse,
|
||||||
|
CompanyNews,
|
||||||
|
CompanyNewsResponse,
|
||||||
|
FinancialMetrics,
|
||||||
|
FinancialMetricsResponse,
|
||||||
|
InsiderTrade,
|
||||||
|
InsiderTradeResponse,
|
||||||
|
LineItem,
|
||||||
|
LineItemResponse,
|
||||||
|
Price,
|
||||||
|
PriceResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DATA_DIR = Path(__file__).parent / "ret_data"
|
||||||
|
|
||||||
|
|
||||||
|
class DataProviderRouter:
|
||||||
|
"""Route data requests across configured providers with fallbacks."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sources = get_data_sources()
|
||||||
|
self._usage = {
|
||||||
|
"preferred": list(self.sources),
|
||||||
|
"last_success": {},
|
||||||
|
}
|
||||||
|
self._listeners: list[Callable[[dict], None]] = []
|
||||||
|
|
||||||
|
def price_sources(self) -> list[DataSource]:
|
||||||
|
"""Price lookup order, always allowing local CSV fallback."""
|
||||||
|
return self.sources
|
||||||
|
|
||||||
|
def api_sources(self) -> list[DataSource]:
|
||||||
|
"""Providers that can serve network-backed data."""
|
||||||
|
return [source for source in self.sources if source != "local_csv"]
|
||||||
|
|
||||||
|
def get_prices(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> tuple[list[Price], DataSource]:
|
||||||
|
"""Fetch prices using preferred providers with fallback."""
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for source in self.price_sources():
|
||||||
|
try:
|
||||||
|
if source == "finnhub":
|
||||||
|
prices = _fetch_finnhub_prices(ticker, start_date, end_date)
|
||||||
|
self._record_success("prices", source)
|
||||||
|
return prices, source
|
||||||
|
if source == "financial_datasets":
|
||||||
|
prices = _fetch_fd_prices(ticker, start_date, end_date)
|
||||||
|
self._record_success("prices", source)
|
||||||
|
return prices, source
|
||||||
|
if source == "yfinance":
|
||||||
|
prices = _fetch_yfinance_prices(ticker, start_date, end_date)
|
||||||
|
self._record_success("prices", source)
|
||||||
|
return prices, source
|
||||||
|
prices = _fetch_local_prices(ticker, start_date, end_date)
|
||||||
|
if prices:
|
||||||
|
self._record_success("prices", source)
|
||||||
|
return prices, source
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
logger.warning("Price source %s failed for %s: %s", source, ticker, exc)
|
||||||
|
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
return [], "local_csv"
|
||||||
|
|
||||||
|
def get_financial_metrics(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
end_date: str,
|
||||||
|
period: str = "ttm",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> tuple[list[FinancialMetrics], DataSource]:
|
||||||
|
"""Fetch financial metrics with API provider fallback."""
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for source in self.api_sources():
|
||||||
|
try:
|
||||||
|
if source == "finnhub":
|
||||||
|
metrics = _fetch_finnhub_financial_metrics(
|
||||||
|
ticker,
|
||||||
|
end_date,
|
||||||
|
period,
|
||||||
|
)
|
||||||
|
self._record_success("financial_metrics", source)
|
||||||
|
return metrics, source
|
||||||
|
if source == "yfinance":
|
||||||
|
metrics = _fetch_yfinance_financial_metrics(
|
||||||
|
ticker,
|
||||||
|
end_date,
|
||||||
|
period,
|
||||||
|
)
|
||||||
|
self._record_success("financial_metrics", source)
|
||||||
|
return metrics, source
|
||||||
|
metrics = _fetch_fd_financial_metrics(
|
||||||
|
ticker,
|
||||||
|
end_date,
|
||||||
|
period,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
self._record_success("financial_metrics", source)
|
||||||
|
return metrics, source
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
logger.warning(
|
||||||
|
"Financial metrics source %s failed for %s: %s",
|
||||||
|
source,
|
||||||
|
ticker,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
return [], "local_csv"
|
||||||
|
|
||||||
|
def search_line_items(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
line_items: list[str],
|
||||||
|
end_date: str,
|
||||||
|
period: str = "ttm",
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[LineItem]:
|
||||||
|
"""Line items are only supported via Financial Datasets."""
|
||||||
|
if "financial_datasets" not in self.api_sources():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
results = _fetch_fd_line_items(
|
||||||
|
ticker=ticker,
|
||||||
|
line_items=line_items,
|
||||||
|
end_date=end_date,
|
||||||
|
period=period,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
self._record_success("line_items", "financial_datasets")
|
||||||
|
return results
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Line items source failed for %s: %s", ticker, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_insider_trades(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
end_date: str,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
limit: int = 1000,
|
||||||
|
) -> tuple[list[InsiderTrade], DataSource]:
|
||||||
|
"""Fetch insider trades with provider fallback."""
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for source in self.api_sources():
|
||||||
|
try:
|
||||||
|
if source == "finnhub":
|
||||||
|
trades = _fetch_finnhub_insider_trades(
|
||||||
|
ticker,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
self._record_success("insider_trades", source)
|
||||||
|
return trades, source
|
||||||
|
trades = _fetch_fd_insider_trades(
|
||||||
|
ticker,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
self._record_success("insider_trades", source)
|
||||||
|
return trades, source
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
logger.warning(
|
||||||
|
"Insider trades source %s failed for %s: %s",
|
||||||
|
source,
|
||||||
|
ticker,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
return [], "local_csv"
|
||||||
|
|
||||||
|
def get_company_news(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
end_date: str,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
limit: int = 1000,
|
||||||
|
) -> tuple[list[CompanyNews], DataSource]:
|
||||||
|
"""Fetch company news with provider fallback."""
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for source in self.api_sources():
|
||||||
|
try:
|
||||||
|
if source == "finnhub":
|
||||||
|
news = _fetch_finnhub_company_news(
|
||||||
|
ticker,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
self._record_success("company_news", source)
|
||||||
|
return news, source
|
||||||
|
if source == "yfinance":
|
||||||
|
news = _fetch_yfinance_company_news(
|
||||||
|
ticker,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
self._record_success("company_news", source)
|
||||||
|
return news, source
|
||||||
|
news = _fetch_fd_company_news(
|
||||||
|
ticker,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
self._record_success("company_news", source)
|
||||||
|
return news, source
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
logger.warning(
|
||||||
|
"Company news source %s failed for %s: %s",
|
||||||
|
source,
|
||||||
|
ticker,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
return [], "local_csv"
|
||||||
|
|
||||||
|
def get_market_cap(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
end_date: str,
|
||||||
|
metrics_lookup,
|
||||||
|
) -> tuple[Optional[float], DataSource]:
|
||||||
|
"""Fetch market cap using facts API or financial metrics fallback."""
|
||||||
|
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||||
|
if end_date == today and "financial_datasets" in self.api_sources():
|
||||||
|
try:
|
||||||
|
self._record_success("market_cap", "financial_datasets")
|
||||||
|
return _fetch_fd_market_cap_today(ticker), "financial_datasets"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Market cap facts source failed for %s: %s",
|
||||||
|
ticker,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics, source = metrics_lookup(ticker, end_date)
|
||||||
|
if not metrics:
|
||||||
|
return None, source
|
||||||
|
market_cap = metrics[0].market_cap
|
||||||
|
if market_cap is None:
|
||||||
|
return None, source
|
||||||
|
if source == "finnhub":
|
||||||
|
self._record_success("market_cap", source)
|
||||||
|
return market_cap * 1_000_000, source
|
||||||
|
self._record_success("market_cap", source)
|
||||||
|
return market_cap, source
|
||||||
|
|
||||||
|
def get_usage_snapshot(self) -> dict:
|
||||||
|
"""Return provider usage metadata for UI/debugging."""
|
||||||
|
return {
|
||||||
|
"preferred": list(self._usage["preferred"]),
|
||||||
|
"last_success": dict(self._usage["last_success"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_listener(self, listener: Callable[[dict], None]) -> None:
|
||||||
|
"""Register a callback for provider usage changes."""
|
||||||
|
if listener not in self._listeners:
|
||||||
|
self._listeners.append(listener)
|
||||||
|
|
||||||
|
def remove_listener(self, listener: Callable[[dict], None]) -> None:
|
||||||
|
"""Remove a previously registered listener."""
|
||||||
|
if listener in self._listeners:
|
||||||
|
self._listeners.remove(listener)
|
||||||
|
|
||||||
|
def load_local_price_frame(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Load local CSV prices as a DataFrame for backtest managers."""
|
||||||
|
csv_path = _DATA_DIR / f"{ticker}.csv"
|
||||||
|
if not csv_path.exists():
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = pd.read_csv(csv_path)
|
||||||
|
if df.empty or "time" not in df.columns:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df["time"] = pd.to_datetime(df["time"])
|
||||||
|
if start_date:
|
||||||
|
df = df[df["time"] >= pd.to_datetime(start_date)]
|
||||||
|
if end_date:
|
||||||
|
df = df[df["time"] <= pd.to_datetime(end_date)]
|
||||||
|
if df.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df["Date"] = pd.to_datetime(df["time"])
|
||||||
|
df.set_index("Date", inplace=True)
|
||||||
|
df.sort_index(inplace=True)
|
||||||
|
self._record_success("historical_prices", "local_csv")
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _record_success(self, data_type: str, source: DataSource) -> None:
|
||||||
|
previous = self._usage["last_success"].get(data_type)
|
||||||
|
self._usage["last_success"][data_type] = source
|
||||||
|
if previous != source:
|
||||||
|
snapshot = self.get_usage_snapshot()
|
||||||
|
for listener in list(self._listeners):
|
||||||
|
try:
|
||||||
|
listener(snapshot)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Provider listener failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
_router_instance: Optional[DataProviderRouter] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_router() -> DataProviderRouter:
|
||||||
|
"""Return a shared provider router instance."""
|
||||||
|
global _router_instance
|
||||||
|
if _router_instance is None:
|
||||||
|
_router_instance = DataProviderRouter()
|
||||||
|
return _router_instance
|
||||||
|
|
||||||
|
|
||||||
|
def _get_finnhub_client() -> finnhub.Client:
|
||||||
|
api_key = _env_required("FINNHUB_API_KEY")
|
||||||
|
return finnhub.Client(api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_required(key: str) -> str:
|
||||||
|
import os
|
||||||
|
|
||||||
|
value = os.getenv(key, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"Missing required API key: {key}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _make_api_request(url: str, headers: dict, method: str = "GET", json_data: dict = None):
|
||||||
|
import requests
|
||||||
|
|
||||||
|
response = (
|
||||||
|
requests.post(url, headers=headers, json=json_data)
|
||||||
|
if method.upper() == "POST"
|
||||||
|
else requests.get(url, headers=headers)
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ValueError(f"{response.status_code} - {response.text}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_local_prices(
|
||||||
|
ticker: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> list[Price]:
|
||||||
|
csv_path = _DATA_DIR / f"{ticker}.csv"
|
||||||
|
if not csv_path.exists():
|
||||||
|
return []
|
||||||
|
df = pd.read_csv(csv_path)
|
||||||
|
if df.empty or "time" not in df.columns:
|
||||||
|
return []
|
||||||
|
df["time"] = pd.to_datetime(df["time"])
|
||||||
|
start = pd.to_datetime(start_date)
|
||||||
|
end = pd.to_datetime(end_date)
|
||||||
|
df = df[(df["time"] >= start) & (df["time"] <= end)].copy()
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
Price(
|
||||||
|
open=float(row["open"]),
|
||||||
|
close=float(row["close"]),
|
||||||
|
high=float(row["high"]),
|
||||||
|
low=float(row["low"]),
|
||||||
|
volume=int(float(row["volume"])),
|
||||||
|
time=row["time"].strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
for _, row in df.iterrows()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_finnhub_prices(
|
||||||
|
ticker: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> list[Price]:
|
||||||
|
client = _get_finnhub_client()
|
||||||
|
start_timestamp = int(
|
||||||
|
datetime.datetime.strptime(start_date, "%Y-%m-%d").timestamp(),
|
||||||
|
)
|
||||||
|
end_timestamp = int(
|
||||||
|
(
|
||||||
|
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
+ datetime.timedelta(days=1)
|
||||||
|
).timestamp(),
|
||||||
|
)
|
||||||
|
candles = client.stock_candles(ticker, "D", start_timestamp, end_timestamp)
|
||||||
|
return [
|
||||||
|
Price(
|
||||||
|
open=candles["o"][i],
|
||||||
|
close=candles["c"][i],
|
||||||
|
high=candles["h"][i],
|
||||||
|
low=candles["l"][i],
|
||||||
|
volume=int(candles["v"][i]),
|
||||||
|
time=datetime.datetime.fromtimestamp(candles["t"][i]).strftime(
|
||||||
|
"%Y-%m-%d",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for i in range(len(candles.get("t", [])))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_yfinance_prices(
|
||||||
|
ticker: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> list[Price]:
|
||||||
|
history = yf.Ticker(ticker).history(
|
||||||
|
start=start_date,
|
||||||
|
end=(
|
||||||
|
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
+ datetime.timedelta(days=1)
|
||||||
|
).strftime("%Y-%m-%d"),
|
||||||
|
auto_adjust=False,
|
||||||
|
actions=False,
|
||||||
|
)
|
||||||
|
if history.empty:
|
||||||
|
return []
|
||||||
|
history = history.reset_index()
|
||||||
|
date_column = "Date" if "Date" in history.columns else history.columns[0]
|
||||||
|
return [
|
||||||
|
Price(
|
||||||
|
open=float(row["Open"]),
|
||||||
|
close=float(row["Close"]),
|
||||||
|
high=float(row["High"]),
|
||||||
|
low=float(row["Low"]),
|
||||||
|
volume=int(float(row["Volume"])),
|
||||||
|
time=pd.to_datetime(row[date_column]).strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
for _, row in history.iterrows()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_fd_prices(
|
||||||
|
ticker: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> list[Price]:
|
||||||
|
headers = {"X-API-KEY": _env_required("FINANCIAL_DATASETS_API_KEY")}
|
||||||
|
url = (
|
||||||
|
"https://api.financialdatasets.ai/prices/"
|
||||||
|
f"?ticker={ticker}&interval=day&interval_multiplier=1"
|
||||||
|
f"&start_date={start_date}&end_date={end_date}"
|
||||||
|
)
|
||||||
|
response = _make_api_request(url, headers)
|
||||||
|
return PriceResponse(**response.json()).prices
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_finnhub_financial_metrics(
|
||||||
|
ticker: str,
|
||||||
|
end_date: str,
|
||||||
|
period: str,
|
||||||
|
) -> list[FinancialMetrics]:
|
||||||
|
client = _get_finnhub_client()
|
||||||
|
financials = client.company_basic_financials(ticker, "all")
|
||||||
|
metric_data = financials.get("metric", {})
|
||||||
|
if not metric_data:
|
||||||
|
return []
|
||||||
|
return [_map_finnhub_metrics(ticker, end_date, period, metric_data)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_fd_financial_metrics(
|
||||||
|
ticker: str,
|
||||||
|
end_date: str,
|
||||||
|
period: str,
|
||||||
|
limit: int,
|
||||||
|
) -> list[FinancialMetrics]:
|
||||||
|
headers = {"X-API-KEY": _env_required("FINANCIAL_DATASETS_API_KEY")}
|
||||||
|
url = (
|
||||||
|
"https://api.financialdatasets.ai/financial-metrics/"
|
||||||
|
f"?ticker={ticker}&report_period_lte={end_date}&limit={limit}&period={period}"
|
||||||
|
)
|
||||||
|
response = _make_api_request(url, headers)
|
||||||
|
return FinancialMetricsResponse(**response.json()).financial_metrics
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_yfinance_financial_metrics(
|
||||||
|
ticker: str,
|
||||||
|
end_date: str,
|
||||||
|
period: str,
|
||||||
|
) -> list[FinancialMetrics]:
|
||||||
|
info = yf.Ticker(ticker).info or {}
|
||||||
|
shares_outstanding = _coerce_float(info.get("sharesOutstanding"))
|
||||||
|
free_cashflow = _coerce_float(info.get("freeCashflow"))
|
||||||
|
return [
|
||||||
|
FinancialMetrics(
|
||||||
|
ticker=ticker,
|
||||||
|
report_period=end_date,
|
||||||
|
period=period,
|
||||||
|
currency=str(info.get("currency") or "USD"),
|
||||||
|
market_cap=_coerce_float(info.get("marketCap")),
|
||||||
|
enterprise_value=_coerce_float(info.get("enterpriseValue")),
|
||||||
|
price_to_earnings_ratio=_coerce_float(info.get("trailingPE")),
|
||||||
|
price_to_book_ratio=_coerce_float(info.get("priceToBook")),
|
||||||
|
price_to_sales_ratio=_coerce_float(
|
||||||
|
info.get("priceToSalesTrailing12Months"),
|
||||||
|
),
|
||||||
|
enterprise_value_to_ebitda_ratio=_coerce_float(
|
||||||
|
info.get("enterpriseToEbitda"),
|
||||||
|
),
|
||||||
|
enterprise_value_to_revenue_ratio=_coerce_float(
|
||||||
|
info.get("enterpriseToRevenue"),
|
||||||
|
),
|
||||||
|
free_cash_flow_yield=_ratio_or_none(free_cashflow, info.get("marketCap")),
|
||||||
|
peg_ratio=_coerce_float(info.get("pegRatio")),
|
||||||
|
gross_margin=_coerce_float(info.get("grossMargins")),
|
||||||
|
operating_margin=_coerce_float(info.get("operatingMargins")),
|
||||||
|
net_margin=_coerce_float(info.get("profitMargins")),
|
||||||
|
return_on_equity=_coerce_float(info.get("returnOnEquity")),
|
||||||
|
return_on_assets=_coerce_float(info.get("returnOnAssets")),
|
||||||
|
return_on_invested_capital=None,
|
||||||
|
asset_turnover=None,
|
||||||
|
inventory_turnover=None,
|
||||||
|
receivables_turnover=None,
|
||||||
|
days_sales_outstanding=None,
|
||||||
|
operating_cycle=None,
|
||||||
|
working_capital_turnover=None,
|
||||||
|
current_ratio=_coerce_float(info.get("currentRatio")),
|
||||||
|
quick_ratio=_coerce_float(info.get("quickRatio")),
|
||||||
|
cash_ratio=None,
|
||||||
|
operating_cash_flow_ratio=None,
|
||||||
|
debt_to_equity=_coerce_float(info.get("debtToEquity")),
|
||||||
|
debt_to_assets=None,
|
||||||
|
interest_coverage=None,
|
||||||
|
revenue_growth=_coerce_float(info.get("revenueGrowth")),
|
||||||
|
earnings_growth=_coerce_float(
|
||||||
|
info.get("earningsGrowth") or info.get("earningsQuarterlyGrowth"),
|
||||||
|
),
|
||||||
|
book_value_growth=None,
|
||||||
|
earnings_per_share_growth=_coerce_float(
|
||||||
|
info.get("earningsQuarterlyGrowth"),
|
||||||
|
),
|
||||||
|
free_cash_flow_growth=None,
|
||||||
|
operating_income_growth=None,
|
||||||
|
ebitda_growth=None,
|
||||||
|
payout_ratio=_coerce_float(info.get("payoutRatio")),
|
||||||
|
earnings_per_share=_coerce_float(info.get("trailingEps")),
|
||||||
|
book_value_per_share=_coerce_float(info.get("bookValue")),
|
||||||
|
free_cash_flow_per_share=_ratio_or_none(free_cashflow, shares_outstanding),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_fd_line_items(
|
||||||
|
ticker: str,
|
||||||
|
line_items: list[str],
|
||||||
|
end_date: str,
|
||||||
|
period: str,
|
||||||
|
limit: int,
|
||||||
|
) -> list[LineItem]:
|
||||||
|
headers = {"X-API-KEY": _env_required("FINANCIAL_DATASETS_API_KEY")}
|
||||||
|
body = {
|
||||||
|
"tickers": [ticker],
|
||||||
|
"line_items": line_items,
|
||||||
|
"end_date": end_date,
|
||||||
|
"period": period,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
response = _make_api_request(
|
||||||
|
"https://api.financialdatasets.ai/financials/search/line-items",
|
||||||
|
headers,
|
||||||
|
method="POST",
|
||||||
|
json_data=body,
|
||||||
|
)
|
||||||
|
return LineItemResponse(**response.json()).search_results[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_finnhub_insider_trades(
|
||||||
|
ticker: str,
|
||||||
|
start_date: Optional[str],
|
||||||
|
end_date: str,
|
||||||
|
limit: int,
|
||||||
|
) -> list[InsiderTrade]:
|
||||||
|
client = _get_finnhub_client()
|
||||||
|
from_date = start_date or (
|
||||||
|
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
- datetime.timedelta(days=365)
|
||||||
|
).strftime("%Y-%m-%d")
|
||||||
|
insider_data = client.stock_insider_transactions(ticker, from_date, end_date)
|
||||||
|
return [
|
||||||
|
_convert_finnhub_insider_trade(ticker, trade)
|
||||||
|
for trade in insider_data.get("data", [])[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_yfinance_company_news(
|
||||||
|
ticker: str,
|
||||||
|
start_date: Optional[str],
|
||||||
|
end_date: str,
|
||||||
|
limit: int,
|
||||||
|
) -> list[CompanyNews]:
|
||||||
|
news_items = getattr(yf.Ticker(ticker), "news", None) or []
|
||||||
|
start_bound = _normalize_timestamp(pd.to_datetime(start_date)) if start_date else None
|
||||||
|
end_bound = _normalize_timestamp(pd.to_datetime(end_date))
|
||||||
|
results: list[CompanyNews] = []
|
||||||
|
|
||||||
|
for item in news_items:
|
||||||
|
content = item.get("content", item)
|
||||||
|
published = (
|
||||||
|
content.get("pubDate")
|
||||||
|
or content.get("displayTime")
|
||||||
|
or item.get("providerPublishTime")
|
||||||
|
)
|
||||||
|
published_dt = _normalize_timestamp(_parse_news_datetime(published))
|
||||||
|
if published_dt is not None and published_dt > end_bound:
|
||||||
|
continue
|
||||||
|
if start_bound is not None and published_dt is not None and published_dt < start_bound:
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = (
|
||||||
|
_nested_get(content, "canonicalUrl", "url")
|
||||||
|
or content.get("clickThroughUrl")
|
||||||
|
or content.get("url")
|
||||||
|
or item.get("link")
|
||||||
|
)
|
||||||
|
title = content.get("title") or item.get("title")
|
||||||
|
if not title or not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
CompanyNews(
|
||||||
|
category=content.get("contentType") or item.get("type"),
|
||||||
|
ticker=ticker,
|
||||||
|
title=title,
|
||||||
|
related=item.get("relatedTickers", [ticker])[0]
|
||||||
|
if item.get("relatedTickers")
|
||||||
|
else ticker,
|
||||||
|
source=_nested_get(content, "provider", "displayName")
|
||||||
|
or item.get("publisher")
|
||||||
|
or "Yahoo Finance",
|
||||||
|
date=published_dt.strftime("%Y-%m-%d") if published_dt else None,
|
||||||
|
url=url,
|
||||||
|
summary=content.get("summary") or item.get("summary"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _map_finnhub_metrics(
|
||||||
|
ticker: str,
|
||||||
|
end_date: str,
|
||||||
|
period: str,
|
||||||
|
metric_data: dict,
|
||||||
|
) -> FinancialMetrics:
|
||||||
|
"""Map Finnhub metric data to FinancialMetrics model."""
|
||||||
|
return FinancialMetrics(
|
||||||
|
ticker=ticker,
|
||||||
|
report_period=end_date,
|
||||||
|
period=period,
|
||||||
|
currency="USD",
|
||||||
|
market_cap=metric_data.get("marketCapitalization"),
|
||||||
|
enterprise_value=None,
|
||||||
|
price_to_earnings_ratio=metric_data.get("peBasicExclExtraTTM"),
|
||||||
|
price_to_book_ratio=metric_data.get("pbAnnual"),
|
||||||
|
price_to_sales_ratio=metric_data.get("psAnnual"),
|
||||||
|
enterprise_value_to_ebitda_ratio=None,
|
||||||
|
enterprise_value_to_revenue_ratio=None,
|
||||||
|
free_cash_flow_yield=None,
|
||||||
|
peg_ratio=None,
|
||||||
|
gross_margin=metric_data.get("grossMarginTTM"),
|
||||||
|
operating_margin=metric_data.get("operatingMarginTTM"),
|
||||||
|
net_margin=metric_data.get("netProfitMarginTTM"),
|
||||||
|
return_on_equity=metric_data.get("roeTTM"),
|
||||||
|
return_on_assets=metric_data.get("roaTTM"),
|
||||||
|
return_on_invested_capital=metric_data.get("roicTTM"),
|
||||||
|
asset_turnover=metric_data.get("assetTurnoverTTM"),
|
||||||
|
inventory_turnover=metric_data.get("inventoryTurnoverTTM"),
|
||||||
|
receivables_turnover=metric_data.get("receivablesTurnoverTTM"),
|
||||||
|
days_sales_outstanding=None,
|
||||||
|
operating_cycle=None,
|
||||||
|
working_capital_turnover=None,
|
||||||
|
current_ratio=metric_data.get("currentRatioAnnual"),
|
||||||
|
quick_ratio=metric_data.get("quickRatioAnnual"),
|
||||||
|
cash_ratio=None,
|
||||||
|
operating_cash_flow_ratio=None,
|
||||||
|
debt_to_equity=metric_data.get("totalDebt/totalEquityAnnual"),
|
||||||
|
debt_to_assets=None,
|
||||||
|
interest_coverage=None,
|
||||||
|
revenue_growth=metric_data.get("revenueGrowthTTMYoy"),
|
||||||
|
earnings_growth=None,
|
||||||
|
book_value_growth=None,
|
||||||
|
earnings_per_share_growth=metric_data.get("epsGrowthTTMYoy"),
|
||||||
|
free_cash_flow_growth=None,
|
||||||
|
operating_income_growth=None,
|
||||||
|
ebitda_growth=None,
|
||||||
|
payout_ratio=metric_data.get("payoutRatioAnnual"),
|
||||||
|
earnings_per_share=metric_data.get("epsBasicExclExtraItemsTTM"),
|
||||||
|
book_value_per_share=metric_data.get("bookValuePerShareAnnual"),
|
||||||
|
free_cash_flow_per_share=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_float(value) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio_or_none(numerator, denominator) -> Optional[float]:
|
||||||
|
top = _coerce_float(numerator)
|
||||||
|
bottom = _coerce_float(denominator)
|
||||||
|
if top is None or bottom in (None, 0.0):
|
||||||
|
return None
|
||||||
|
return top / bottom
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_get(payload: dict, *keys: str):
|
||||||
|
current = payload
|
||||||
|
for key in keys:
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return None
|
||||||
|
current = current.get(key)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_news_datetime(value) -> Optional[pd.Timestamp]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return pd.to_datetime(int(value), unit="s")
|
||||||
|
return pd.to_datetime(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_timestamp(value: Optional[pd.Timestamp]) -> Optional[pd.Timestamp]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is not None:
|
||||||
|
return value.tz_convert(None)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_finnhub_insider_trade(ticker: str, trade: dict) -> InsiderTrade:
|
||||||
|
"""Convert Finnhub insider trade format to InsiderTrade model."""
|
||||||
|
shares_after = trade.get("share", 0)
|
||||||
|
change = trade.get("change", 0)
|
||||||
|
|
||||||
|
return InsiderTrade(
|
||||||
|
ticker=ticker,
|
||||||
|
issuer=None,
|
||||||
|
name=trade.get("name", ""),
|
||||||
|
title=None,
|
||||||
|
is_board_director=None,
|
||||||
|
transaction_date=trade.get("transactionDate", ""),
|
||||||
|
transaction_shares=abs(change),
|
||||||
|
transaction_price_per_share=trade.get("transactionPrice", 0.0),
|
||||||
|
transaction_value=abs(change) * trade.get("transactionPrice", 0.0),
|
||||||
|
shares_owned_before_transaction=(
|
||||||
|
shares_after - change if shares_after and change else None
|
||||||
|
),
|
||||||
|
shares_owned_after_transaction=float(shares_after)
|
||||||
|
if shares_after
|
||||||
|
else None,
|
||||||
|
security_title=None,
|
||||||
|
filing_date=trade.get("filingDate", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_fd_insider_trades(
|
||||||
|
ticker: str,
|
||||||
|
start_date: Optional[str],
|
||||||
|
end_date: str,
|
||||||
|
limit: int,
|
||||||
|
) -> list[InsiderTrade]:
|
||||||
|
headers = {"X-API-KEY": _env_required("FINANCIAL_DATASETS_API_KEY")}
|
||||||
|
url = f"https://api.financialdatasets.ai/insider-trades/?ticker={ticker}&filing_date_lte={end_date}"
|
||||||
|
if start_date:
|
||||||
|
url += f"&filing_date_gte={start_date}"
|
||||||
|
url += f"&limit={limit}"
|
||||||
|
response = _make_api_request(url, headers)
|
||||||
|
return InsiderTradeResponse(**response.json()).insider_trades
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_finnhub_company_news(
|
||||||
|
ticker: str,
|
||||||
|
start_date: Optional[str],
|
||||||
|
end_date: str,
|
||||||
|
limit: int,
|
||||||
|
) -> list[CompanyNews]:
|
||||||
|
client = _get_finnhub_client()
|
||||||
|
from_date = start_date or (
|
||||||
|
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
- datetime.timedelta(days=30)
|
||||||
|
).strftime("%Y-%m-%d")
|
||||||
|
news_data = client.company_news(ticker, _from=from_date, to=end_date)
|
||||||
|
return [
|
||||||
|
CompanyNews(
|
||||||
|
ticker=ticker,
|
||||||
|
title=news_item.get("headline", ""),
|
||||||
|
related=news_item.get("related", ""),
|
||||||
|
source=news_item.get("source", ""),
|
||||||
|
date=(
|
||||||
|
datetime.datetime.fromtimestamp(
|
||||||
|
news_item.get("datetime", 0),
|
||||||
|
datetime.timezone.utc,
|
||||||
|
).strftime("%Y-%m-%d")
|
||||||
|
if news_item.get("datetime")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
url=news_item.get("url", ""),
|
||||||
|
summary=news_item.get("summary", ""),
|
||||||
|
category=news_item.get("category", ""),
|
||||||
|
)
|
||||||
|
for news_item in news_data[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_fd_company_news(
|
||||||
|
ticker: str,
|
||||||
|
start_date: Optional[str],
|
||||||
|
end_date: str,
|
||||||
|
limit: int,
|
||||||
|
) -> list[CompanyNews]:
|
||||||
|
headers = {"X-API-KEY": _env_required("FINANCIAL_DATASETS_API_KEY")}
|
||||||
|
url = f"https://api.financialdatasets.ai/news/?ticker={ticker}&end_date={end_date}&limit={limit}"
|
||||||
|
if start_date:
|
||||||
|
url += f"&start_date={start_date}"
|
||||||
|
response = _make_api_request(url, headers)
|
||||||
|
return CompanyNewsResponse(**response.json()).news
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_fd_market_cap_today(ticker: str) -> Optional[float]:
|
||||||
|
headers = {"X-API-KEY": _env_required("FINANCIAL_DATASETS_API_KEY")}
|
||||||
|
url = f"https://api.financialdatasets.ai/company/facts/?ticker={ticker}"
|
||||||
|
response = _make_api_request(url, headers)
|
||||||
|
return CompanyFactsResponse(**response.json()).company_facts.market_cap
|
||||||
67
backend/data/provider_utils.py
Normal file
67
backend/data/provider_utils.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Shared market symbol normalization helpers."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MarketSymbol:
|
||||||
|
"""Normalized symbol metadata."""
|
||||||
|
|
||||||
|
raw: str
|
||||||
|
canonical: str
|
||||||
|
market: str
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_symbol(symbol: str) -> str:
|
||||||
|
"""Return canonical uppercase symbol for storage and routing."""
|
||||||
|
return (symbol or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_symbol(symbol: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize symbols across US and exchange-prefixed formats.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- sh600519 -> 600519
|
||||||
|
- 600519.SH -> 600519
|
||||||
|
- aapl -> AAPL
|
||||||
|
- hk00700 -> HK00700
|
||||||
|
"""
|
||||||
|
canonical = canonical_symbol(symbol)
|
||||||
|
|
||||||
|
if canonical.startswith(("SH", "SZ", "BJ")) and len(canonical) > 2:
|
||||||
|
candidate = canonical[2:]
|
||||||
|
if candidate.isdigit() and len(candidate) in (5, 6):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
if "." in canonical:
|
||||||
|
base, suffix = canonical.rsplit(".", 1)
|
||||||
|
if suffix in {"SH", "SZ", "SS", "BJ"} and base.isdigit():
|
||||||
|
return base
|
||||||
|
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
|
||||||
|
def detect_market(symbol: str) -> str:
|
||||||
|
"""Infer market tag from normalized symbol."""
|
||||||
|
normalized = normalize_symbol(symbol)
|
||||||
|
if normalized.startswith("HK") or (
|
||||||
|
normalized.isdigit() and len(normalized) == 5
|
||||||
|
):
|
||||||
|
return "hk"
|
||||||
|
if normalized.isalpha() or (
|
||||||
|
"/" not in normalized and not normalized.isdigit()
|
||||||
|
):
|
||||||
|
return "us"
|
||||||
|
return "cn"
|
||||||
|
|
||||||
|
|
||||||
|
def describe_symbol(symbol: str) -> MarketSymbol:
|
||||||
|
"""Return normalized symbol metadata."""
|
||||||
|
normalized = normalize_symbol(symbol)
|
||||||
|
return MarketSymbol(
|
||||||
|
raw=symbol,
|
||||||
|
canonical=normalized,
|
||||||
|
market=detect_market(normalized),
|
||||||
|
)
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
AgentScope Native Model Factory
|
AgentScope Native Model Factory
|
||||||
Uses native AgentScope model classes for LLM calls
|
Uses native AgentScope model classes for LLM calls
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
import os
|
||||||
from agentscope.formatter import (
|
from agentscope.formatter import (
|
||||||
AnthropicChatFormatter,
|
AnthropicChatFormatter,
|
||||||
DashScopeChatFormatter,
|
DashScopeChatFormatter,
|
||||||
@@ -20,6 +20,11 @@ from agentscope.model import (
|
|||||||
OllamaChatModel,
|
OllamaChatModel,
|
||||||
OpenAIChatModel,
|
OpenAIChatModel,
|
||||||
)
|
)
|
||||||
|
from backend.config.env_config import (
|
||||||
|
canonicalize_model_provider,
|
||||||
|
get_agent_model_config,
|
||||||
|
get_env_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModelProvider(Enum):
|
class ModelProvider(Enum):
|
||||||
@@ -108,7 +113,7 @@ def create_model(
|
|||||||
Returns:
|
Returns:
|
||||||
AgentScope model instance
|
AgentScope model instance
|
||||||
"""
|
"""
|
||||||
provider = provider.upper()
|
provider = canonicalize_model_provider(provider)
|
||||||
|
|
||||||
model_class = PROVIDER_MODEL_MAP.get(provider)
|
model_class = PROVIDER_MODEL_MAP.get(provider)
|
||||||
if model_class is None:
|
if model_class is None:
|
||||||
@@ -138,19 +143,21 @@ def create_model(
|
|||||||
|
|
||||||
# Handle custom OpenAI base URL
|
# Handle custom OpenAI base URL
|
||||||
if provider == "OPENAI":
|
if provider == "OPENAI":
|
||||||
base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE")
|
base_url = get_env_str("OPENAI_BASE_URL") or get_env_str(
|
||||||
|
"OPENAI_API_BASE",
|
||||||
|
)
|
||||||
if base_url:
|
if base_url:
|
||||||
model_kwargs["client_args"] = {"base_url": base_url}
|
model_kwargs["client_args"] = {"base_url": base_url}
|
||||||
|
|
||||||
# Handle DashScope base URL (uses different parameter)
|
# Handle DashScope base URL (uses different parameter)
|
||||||
if provider in ("DASHSCOPE", "ALIBABA"):
|
if provider in ("DASHSCOPE", "ALIBABA"):
|
||||||
base_url = os.getenv("DASHSCOPE_BASE_URL")
|
base_url = get_env_str("DASHSCOPE_BASE_URL")
|
||||||
if base_url:
|
if base_url:
|
||||||
model_kwargs["base_http_api_url"] = base_url
|
model_kwargs["base_http_api_url"] = base_url
|
||||||
|
|
||||||
# Handle Ollama host
|
# Handle Ollama host
|
||||||
if provider == "OLLAMA":
|
if provider == "OLLAMA":
|
||||||
host = os.getenv("OLLAMA_HOST")
|
host = get_env_str("OLLAMA_HOST")
|
||||||
if host:
|
if host:
|
||||||
model_kwargs["host"] = host
|
model_kwargs["host"] = host
|
||||||
|
|
||||||
@@ -174,23 +181,11 @@ def get_agent_model(agent_id: str, stream: bool = False):
|
|||||||
Returns:
|
Returns:
|
||||||
AgentScope model instance
|
AgentScope model instance
|
||||||
"""
|
"""
|
||||||
# Normalize agent_id to uppercase for env var lookup
|
resolved = get_agent_model_config(agent_id)
|
||||||
agent_key = agent_id.upper().replace("-", "_")
|
|
||||||
|
|
||||||
# Try agent-specific config first
|
|
||||||
model_name = os.getenv(f"AGENT_{agent_key}_MODEL_NAME")
|
|
||||||
provider = os.getenv(f"AGENT_{agent_key}_MODEL_PROVIDER")
|
|
||||||
|
|
||||||
print(f"Using specific model {model_name} for agent {agent_key}")
|
|
||||||
# Fall back to global config
|
|
||||||
if not model_name:
|
|
||||||
model_name = os.getenv("MODEL_NAME", "gpt-4o")
|
|
||||||
if not provider:
|
|
||||||
provider = os.getenv("MODEL_PROVIDER", "OPENAI")
|
|
||||||
|
|
||||||
return create_model(
|
return create_model(
|
||||||
model_name=model_name,
|
model_name=resolved.model_name,
|
||||||
provider=provider,
|
provider=resolved.provider,
|
||||||
stream=stream,
|
stream=stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,17 +200,7 @@ def get_agent_formatter(agent_id: str):
|
|||||||
Returns:
|
Returns:
|
||||||
AgentScope formatter instance
|
AgentScope formatter instance
|
||||||
"""
|
"""
|
||||||
# Normalize agent_id to uppercase for env var lookup
|
provider = get_agent_model_config(agent_id).provider
|
||||||
agent_key = agent_id.upper().replace("-", "_")
|
|
||||||
|
|
||||||
# Try agent-specific config first
|
|
||||||
provider = os.getenv(f"AGENT_{agent_key}_MODEL_PROVIDER")
|
|
||||||
|
|
||||||
# Fall back to global config
|
|
||||||
if not provider:
|
|
||||||
provider = os.getenv("MODEL_PROVIDER", "OPENAI")
|
|
||||||
|
|
||||||
provider = provider.upper()
|
|
||||||
formatter_class = PROVIDER_FORMATTER_MAP.get(provider, OpenAIChatFormatter)
|
formatter_class = PROVIDER_FORMATTER_MAP.get(provider, OpenAIChatFormatter)
|
||||||
return formatter_class()
|
return formatter_class()
|
||||||
|
|
||||||
@@ -230,14 +215,5 @@ def get_agent_model_info(agent_id: str) -> Tuple[str, str]:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (model_name, provider_name)
|
Tuple of (model_name, provider_name)
|
||||||
"""
|
"""
|
||||||
agent_key = agent_id.upper().replace("-", "_")
|
resolved = get_agent_model_config(agent_id)
|
||||||
|
return resolved.model_name, resolved.provider
|
||||||
model_name = os.getenv(f"AGENT_{agent_key}_MODEL_NAME")
|
|
||||||
provider = os.getenv(f"AGENT_{agent_key}_MODEL_PROVIDER")
|
|
||||||
|
|
||||||
if not model_name:
|
|
||||||
model_name = os.getenv("MODEL_NAME", "gpt-4o")
|
|
||||||
if not provider:
|
|
||||||
provider = os.getenv("MODEL_PROVIDER", "OPENAI")
|
|
||||||
|
|
||||||
return model_name, provider.upper()
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from backend.core.pipeline import TradingPipeline
|
|||||||
from backend.core.state_sync import StateSync
|
from backend.core.state_sync import StateSync
|
||||||
from backend.services.market import MarketService
|
from backend.services.market import MarketService
|
||||||
from backend.services.storage import StorageService
|
from backend.services.storage import StorageService
|
||||||
|
from backend.data.provider_router import get_provider_router
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -60,10 +61,14 @@ class Gateway:
|
|||||||
|
|
||||||
# Session tracking for live returns
|
# Session tracking for live returns
|
||||||
self._session_start_portfolio_value: Optional[float] = None
|
self._session_start_portfolio_value: Optional[float] = None
|
||||||
|
self._provider_router = get_provider_router()
|
||||||
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
async def start(self, host: str = "0.0.0.0", port: int = 8766):
|
async def start(self, host: str = "0.0.0.0", port: int = 8766):
|
||||||
"""Start gateway server"""
|
"""Start gateway server"""
|
||||||
logger.info(f"Starting gateway on {host}:{port}")
|
logger.info(f"Starting gateway on {host}:{port}")
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self._provider_router.add_listener(self._on_provider_usage_changed)
|
||||||
|
|
||||||
# Initialize terminal dashboard
|
# Initialize terminal dashboard
|
||||||
self._dashboard.set_config(
|
self._dashboard.set_config(
|
||||||
@@ -77,6 +82,7 @@ class Gateway:
|
|||||||
initial_cash=self.storage.initial_cash,
|
initial_cash=self.storage.initial_cash,
|
||||||
start_date=self._backtest_start_date or "",
|
start_date=self._backtest_start_date or "",
|
||||||
end_date=self._backtest_end_date or "",
|
end_date=self._backtest_end_date or "",
|
||||||
|
data_sources=self._provider_router.get_usage_snapshot(),
|
||||||
)
|
)
|
||||||
self._dashboard.start()
|
self._dashboard.start()
|
||||||
|
|
||||||
@@ -88,6 +94,10 @@ class Gateway:
|
|||||||
"is_mock_mode",
|
"is_mock_mode",
|
||||||
self.config.get("mock_mode", False),
|
self.config.get("mock_mode", False),
|
||||||
)
|
)
|
||||||
|
self.state_sync.update_state(
|
||||||
|
"data_sources",
|
||||||
|
self._provider_router.get_usage_snapshot(),
|
||||||
|
)
|
||||||
|
|
||||||
# Load and display existing portfolio state if available
|
# Load and display existing portfolio state if available
|
||||||
summary = self.storage.load_file("summary")
|
summary = self.storage.load_file("summary")
|
||||||
@@ -130,6 +140,21 @@ class Gateway:
|
|||||||
)
|
)
|
||||||
await asyncio.Future()
|
await asyncio.Future()
|
||||||
|
|
||||||
|
def _on_provider_usage_changed(self, snapshot: Dict[str, Any]):
|
||||||
|
"""Handle provider routing updates from the shared router."""
|
||||||
|
self.state_sync.update_state("data_sources", snapshot)
|
||||||
|
self._dashboard.update(data_sources=snapshot)
|
||||||
|
if self._loop and self._loop.is_running():
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self.broadcast(
|
||||||
|
{
|
||||||
|
"type": "data_sources_update",
|
||||||
|
"data_sources": snapshot,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
self._loop,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> Dict[str, Any]:
|
def state(self) -> Dict[str, Any]:
|
||||||
return self.state_sync.state
|
return self.state_sync.state
|
||||||
@@ -149,6 +174,9 @@ class Gateway:
|
|||||||
state_payload = self.state_sync.get_initial_state_payload(
|
state_payload = self.state_sync.get_initial_state_payload(
|
||||||
include_dashboard=True,
|
include_dashboard=True,
|
||||||
)
|
)
|
||||||
|
state_payload["data_sources"] = (
|
||||||
|
self._provider_router.get_usage_snapshot()
|
||||||
|
)
|
||||||
# Include market status in initial state
|
# Include market status in initial state
|
||||||
state_payload[
|
state_payload[
|
||||||
"market_status"
|
"market_status"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pandas_market_calendars as mcal
|
import pandas_market_calendars as mcal
|
||||||
|
from backend.config.data_config import get_data_source
|
||||||
|
from backend.data.provider_utils import normalize_symbol
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ class MarketService:
|
|||||||
backtest_start_date: Optional[str] = None,
|
backtest_start_date: Optional[str] = None,
|
||||||
backtest_end_date: Optional[str] = None,
|
backtest_end_date: Optional[str] = None,
|
||||||
):
|
):
|
||||||
self.tickers = tickers
|
self.tickers = [normalize_symbol(ticker) for ticker in tickers]
|
||||||
self.poll_interval = poll_interval
|
self.poll_interval = poll_interval
|
||||||
self.mock_mode = mock_mode
|
self.mock_mode = mock_mode
|
||||||
self.backtest_mode = backtest_mode
|
self.backtest_mode = backtest_mode
|
||||||
@@ -123,11 +125,16 @@ class MarketService:
|
|||||||
def _start_real_mode(self):
|
def _start_real_mode(self):
|
||||||
from backend.data.polling_price_manager import PollingPriceManager
|
from backend.data.polling_price_manager import PollingPriceManager
|
||||||
|
|
||||||
if not self.api_key:
|
provider = get_data_source()
|
||||||
|
if provider == "local_csv":
|
||||||
|
provider = "yfinance"
|
||||||
|
|
||||||
|
if provider == "finnhub" and not self.api_key:
|
||||||
raise ValueError("API key required for live mode")
|
raise ValueError("API key required for live mode")
|
||||||
self._price_manager = PollingPriceManager(
|
self._price_manager = PollingPriceManager(
|
||||||
api_key=self.api_key,
|
api_key=self.api_key,
|
||||||
poll_interval=self.poll_interval,
|
poll_interval=self.poll_interval,
|
||||||
|
provider=provider,
|
||||||
)
|
)
|
||||||
self._price_manager.add_price_callback(self._make_price_callback())
|
self._price_manager.add_price_callback(self._make_price_callback())
|
||||||
self._price_manager.subscribe(self.tickers)
|
self._price_manager.subscribe(self.tickers)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class TestAnalystAgent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert agent.analyst_type_key == "technical_analyst"
|
assert agent.analyst_type_key == "technical_analyst"
|
||||||
assert agent.name == "technical_analyst_analyst"
|
assert agent.name == "technical_analyst"
|
||||||
assert agent.analyst_persona == "Technical Analyst"
|
assert agent.analyst_persona == "Technical Analyst"
|
||||||
|
|
||||||
def test_init_invalid_analyst_type(self):
|
def test_init_invalid_analyst_type(self):
|
||||||
|
|||||||
10
backend/tests/test_analysis_tools.py
Normal file
10
backend/tests/test_analysis_tools.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from backend.tools.analysis_tools import _resolved_date
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolved_date_clamps_future_date():
|
||||||
|
future_date = (datetime.today() + timedelta(days=2)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
assert _resolved_date(future_date) == datetime.today().strftime("%Y-%m-%d")
|
||||||
55
backend/tests/test_data_config.py
Normal file
55
backend/tests/test_data_config.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for data source config ordering."""
|
||||||
|
|
||||||
|
from backend.config.data_config import get_config, reset_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_config_prefers_env_source(monkeypatch):
|
||||||
|
monkeypatch.setenv("FIN_DATA_SOURCE", "financial_datasets")
|
||||||
|
monkeypatch.setenv("FINNHUB_API_KEY", "fh")
|
||||||
|
monkeypatch.setenv("FINANCIAL_DATASETS_API_KEY", "fd")
|
||||||
|
reset_config()
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
assert config.sources[0] == "financial_datasets"
|
||||||
|
assert "local_csv" in config.sources
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_data_sources_filters_available_sources(monkeypatch):
|
||||||
|
monkeypatch.setenv("FINNHUB_API_KEY", "fh-key")
|
||||||
|
monkeypatch.setenv("FINANCIAL_DATASETS_API_KEY", "fd-key")
|
||||||
|
monkeypatch.setenv("ENABLED_DATA_SOURCES", "financial_datasets,local_csv")
|
||||||
|
monkeypatch.delenv("FIN_DATA_SOURCE", raising=False)
|
||||||
|
reset_config()
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
assert config.sources == ["financial_datasets", "local_csv"]
|
||||||
|
assert config.source == "financial_datasets"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preferred_source_reorders_enabled_sources(monkeypatch):
|
||||||
|
monkeypatch.setenv("FINNHUB_API_KEY", "fh-key")
|
||||||
|
monkeypatch.setenv("FINANCIAL_DATASETS_API_KEY", "fd-key")
|
||||||
|
monkeypatch.setenv("ENABLED_DATA_SOURCES", "financial_datasets,finnhub,local_csv")
|
||||||
|
monkeypatch.setenv("FIN_DATA_SOURCE", "finnhub")
|
||||||
|
reset_config()
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
assert config.sources == ["finnhub", "financial_datasets", "local_csv"]
|
||||||
|
assert config.source == "finnhub"
|
||||||
|
|
||||||
|
|
||||||
|
def test_yfinance_can_be_enabled_without_api_key(monkeypatch):
|
||||||
|
monkeypatch.delenv("FINNHUB_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("FINANCIAL_DATASETS_API_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("FIN_DATA_SOURCE", "yfinance")
|
||||||
|
monkeypatch.setenv("ENABLED_DATA_SOURCES", "yfinance,local_csv")
|
||||||
|
reset_config()
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
assert config.sources == ["yfinance", "local_csv"]
|
||||||
|
assert config.source == "yfinance"
|
||||||
25
backend/tests/test_env_config.py
Normal file
25
backend/tests/test_env_config.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for normalized env config helpers."""
|
||||||
|
|
||||||
|
from backend.config.env_config import (
|
||||||
|
canonicalize_model_provider,
|
||||||
|
get_agent_model_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonicalize_model_provider_aliases():
|
||||||
|
assert canonicalize_model_provider("claude") == "ANTHROPIC"
|
||||||
|
assert canonicalize_model_provider("openai_compatible") == "OPENAI"
|
||||||
|
assert canonicalize_model_provider("google") == "GEMINI"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_agent_model_config_fallback(monkeypatch):
|
||||||
|
monkeypatch.delenv("AGENT_RISK_MANAGER_MODEL_NAME", raising=False)
|
||||||
|
monkeypatch.delenv("AGENT_RISK_MANAGER_MODEL_PROVIDER", raising=False)
|
||||||
|
monkeypatch.setenv("MODEL_NAME", "gpt-4o-mini")
|
||||||
|
monkeypatch.setenv("MODEL_PROVIDER", "openai")
|
||||||
|
|
||||||
|
config = get_agent_model_config("risk_manager")
|
||||||
|
|
||||||
|
assert config.model_name == "gpt-4o-mini"
|
||||||
|
assert config.provider == "OPENAI"
|
||||||
@@ -157,6 +157,15 @@ class TestPollingPriceManager:
|
|||||||
|
|
||||||
assert manager.api_key == "test_key"
|
assert manager.api_key == "test_key"
|
||||||
assert manager.poll_interval == 30
|
assert manager.poll_interval == 30
|
||||||
|
assert manager.provider == "finnhub"
|
||||||
|
assert manager.running is False
|
||||||
|
|
||||||
|
def test_init_yfinance(self):
|
||||||
|
manager = PollingPriceManager(provider="yfinance", poll_interval=15)
|
||||||
|
|
||||||
|
assert manager.api_key is None
|
||||||
|
assert manager.poll_interval == 15
|
||||||
|
assert manager.provider == "yfinance"
|
||||||
assert manager.running is False
|
assert manager.running is False
|
||||||
|
|
||||||
def test_subscribe(self):
|
def test_subscribe(self):
|
||||||
@@ -182,7 +191,7 @@ class TestPollingPriceManager:
|
|||||||
assert callback in manager.price_callbacks
|
assert callback in manager.price_callbacks
|
||||||
|
|
||||||
@patch.object(PollingPriceManager, "_fetch_prices")
|
@patch.object(PollingPriceManager, "_fetch_prices")
|
||||||
def test_start_stop(self):
|
def test_start_stop(self, _mock_fetch_prices):
|
||||||
manager = PollingPriceManager(api_key="test_key", poll_interval=1)
|
manager = PollingPriceManager(api_key="test_key", poll_interval=1)
|
||||||
manager.subscribe(["AAPL"])
|
manager.subscribe(["AAPL"])
|
||||||
|
|
||||||
@@ -246,6 +255,20 @@ class TestMarketService:
|
|||||||
assert service.mock_mode is False
|
assert service.mock_mode is False
|
||||||
assert service.api_key == "test_key"
|
assert service.api_key == "test_key"
|
||||||
|
|
||||||
|
@patch("backend.services.market.get_data_source", return_value="yfinance")
|
||||||
|
@patch.object(PollingPriceManager, "start")
|
||||||
|
def test_start_real_mode_with_yfinance(self, _mock_start, _mock_source):
|
||||||
|
service = MarketService(
|
||||||
|
tickers=["AAPL"],
|
||||||
|
poll_interval=10,
|
||||||
|
mock_mode=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
service._start_real_mode()
|
||||||
|
|
||||||
|
assert isinstance(service._price_manager, PollingPriceManager)
|
||||||
|
assert service._price_manager.provider == "yfinance"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_mock_mode(self):
|
async def test_start_mock_mode(self):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
@@ -264,8 +287,9 @@ class TestMarketService:
|
|||||||
|
|
||||||
service.stop()
|
service.stop()
|
||||||
|
|
||||||
|
@patch("backend.services.market.get_data_source", return_value="finnhub")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_real_mode_without_api_key(self):
|
async def test_start_real_mode_without_api_key(self, _mock_source):
|
||||||
service = MarketService(
|
service = MarketService(
|
||||||
tickers=["AAPL"],
|
tickers=["AAPL"],
|
||||||
mock_mode=False,
|
mock_mode=False,
|
||||||
|
|||||||
29
backend/tests/test_provider_router.py
Normal file
29
backend/tests/test_provider_router.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for provider router fallback behavior."""
|
||||||
|
|
||||||
|
from backend.data.provider_router import DataProviderRouter
|
||||||
|
from backend.config.data_config import reset_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_includes_local_csv_fallback(monkeypatch):
|
||||||
|
monkeypatch.delenv("FINNHUB_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("FINANCIAL_DATASETS_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("FIN_DATA_SOURCE", raising=False)
|
||||||
|
reset_config()
|
||||||
|
|
||||||
|
router = DataProviderRouter()
|
||||||
|
|
||||||
|
assert router.price_sources() == ["local_csv"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_allows_yfinance_when_enabled(monkeypatch):
|
||||||
|
monkeypatch.setenv("FIN_DATA_SOURCE", "yfinance")
|
||||||
|
monkeypatch.setenv("ENABLED_DATA_SOURCES", "yfinance,local_csv")
|
||||||
|
monkeypatch.delenv("FINNHUB_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("FINANCIAL_DATASETS_API_KEY", raising=False)
|
||||||
|
reset_config()
|
||||||
|
|
||||||
|
router = DataProviderRouter()
|
||||||
|
|
||||||
|
assert router.price_sources() == ["yfinance", "local_csv"]
|
||||||
|
assert router.api_sources() == ["yfinance"]
|
||||||
15
backend/tests/test_provider_utils.py
Normal file
15
backend/tests/test_provider_utils.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for market symbol normalization helpers."""
|
||||||
|
|
||||||
|
from backend.data.provider_utils import describe_symbol, normalize_symbol
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_symbol_exchange_prefix():
|
||||||
|
assert normalize_symbol("sh600519") == "600519"
|
||||||
|
assert normalize_symbol("600519.SH") == "600519"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_symbol_us_ticker():
|
||||||
|
symbol = describe_symbol("aapl")
|
||||||
|
assert symbol.canonical == "AAPL"
|
||||||
|
assert symbol.market == "us"
|
||||||
22
backend/tests/test_technical_signals.py
Normal file
22
backend/tests/test_technical_signals.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for structured technical analyzer."""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from backend.tools.technical_signals import StockTechnicalAnalyzer
|
||||||
|
|
||||||
|
|
||||||
|
def test_technical_analyzer_detects_bullish_trend():
|
||||||
|
df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"time": pd.date_range("2024-01-01", periods=40, freq="D"),
|
||||||
|
"close": [100 + i for i in range(40)],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
analyzer = StockTechnicalAnalyzer()
|
||||||
|
|
||||||
|
result = analyzer.analyze("AAPL", df)
|
||||||
|
|
||||||
|
assert result.current_price == 139.0
|
||||||
|
assert result.trend in {"BULLISH", "STRONG BULLISH"}
|
||||||
|
assert result.momentum_20d_pct > 0
|
||||||
@@ -29,8 +29,10 @@ from backend.tools.data_tools import (
|
|||||||
prices_to_df,
|
prices_to_df,
|
||||||
search_line_items,
|
search_line_items,
|
||||||
)
|
)
|
||||||
|
from backend.tools.technical_signals import StockTechnicalAnalyzer
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
_technical_analyzer = StockTechnicalAnalyzer()
|
||||||
|
|
||||||
|
|
||||||
def _to_text_response(text: str) -> ToolResponse:
|
def _to_text_response(text: str) -> ToolResponse:
|
||||||
@@ -108,7 +110,12 @@ def _fmt(val, fmt=".2f", suffix="") -> str:
|
|||||||
|
|
||||||
def _resolved_date(current_date: Optional[str]) -> str:
|
def _resolved_date(current_date: Optional[str]) -> str:
|
||||||
"""Ensure we always return a concrete date string."""
|
"""Ensure we always return a concrete date string."""
|
||||||
return current_date or datetime.today().strftime("%Y-%m-%d")
|
today = datetime.today().date()
|
||||||
|
if not current_date:
|
||||||
|
return today.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
requested_date = datetime.strptime(current_date, "%Y-%m-%d").date()
|
||||||
|
return min(requested_date, today).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
# ==================== Fundamental Analysis Tools ====================
|
# ==================== Fundamental Analysis Tools ====================
|
||||||
@@ -419,60 +426,33 @@ def analyze_trend_following(
|
|||||||
lines.append(f"{ticker}: Insufficient price data\n")
|
lines.append(f"{ticker}: Insufficient price data\n")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
df = prices_to_df(prices)
|
signal = _technical_analyzer.analyze(ticker, prices_to_df(prices))
|
||||||
n = len(df)
|
distance_200ma = (
|
||||||
|
((signal.current_price - signal.ma200) / signal.ma200) * 100
|
||||||
# Calculate moving averages
|
if signal.ma200
|
||||||
sma_20_win = min(20, n // 2)
|
|
||||||
sma_50_win = min(50, n - 5) if n > 25 else min(25, n - 5)
|
|
||||||
sma_200_win = min(200, n - 10) if n > 200 else None
|
|
||||||
|
|
||||||
df["SMA_20"] = df["close"].rolling(window=sma_20_win).mean()
|
|
||||||
df["SMA_50"] = df["close"].rolling(window=sma_50_win).mean()
|
|
||||||
if sma_200_win:
|
|
||||||
df["SMA_200"] = df["close"].rolling(window=sma_200_win).mean()
|
|
||||||
|
|
||||||
df["EMA_12"] = df["close"].ewm(span=min(12, n // 3)).mean()
|
|
||||||
df["EMA_26"] = df["close"].ewm(span=min(26, n // 2)).mean()
|
|
||||||
df["MACD"] = df["EMA_12"] - df["EMA_26"]
|
|
||||||
df["MACD_signal"] = df["MACD"].ewm(span=9).mean()
|
|
||||||
|
|
||||||
current_price = _safe_float(df["close"].iloc[-1])
|
|
||||||
sma_20 = _safe_float(df["SMA_20"].iloc[-1])
|
|
||||||
sma_50 = _safe_float(df["SMA_50"].iloc[-1])
|
|
||||||
sma_200 = (
|
|
||||||
_safe_float(df["SMA_200"].iloc[-1])
|
|
||||||
if "SMA_200" in df.columns
|
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
macd = _safe_float(df["MACD"].iloc[-1])
|
macd_signal_str = (
|
||||||
macd_signal = _safe_float(df["MACD_signal"].iloc[-1])
|
"BUY" if signal.macd > signal.macd_signal else "SELL"
|
||||||
|
)
|
||||||
|
|
||||||
# Determine trend
|
lines.append(f"{ticker}: ${signal.current_price:.2f}")
|
||||||
if sma_200:
|
|
||||||
trend = "BULLISH" if current_price > sma_200 else "BEARISH"
|
|
||||||
distance_200ma = ((current_price - sma_200) / sma_200) * 100
|
|
||||||
else:
|
|
||||||
trend = "UNKNOWN"
|
|
||||||
distance_200ma = None
|
|
||||||
|
|
||||||
macd_signal_str = "BUY" if macd > macd_signal else "SELL"
|
|
||||||
|
|
||||||
lines.append(f"{ticker}: ${current_price:.2f}")
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f" SMA20: ${sma_20:.2f} | SMA50: ${sma_50:.2f} | SMA200: {f'${sma_200:.2f}' if sma_200 else 'N/A'}",
|
f" MA20: ${signal.ma20:.2f} | MA50: ${signal.ma50:.2f} | MA200: {f'${signal.ma200:.2f}' if signal.ma200 else 'N/A'}",
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
f" MACD: {macd:.3f} | Signal: {macd_signal:.3f} -> {macd_signal_str}",
|
f" MACD: {signal.macd:.3f} | Signal: {signal.macd_signal:.3f} -> {macd_signal_str}",
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
f" Long-term Trend: {trend}"
|
f" Long-term Trend: {signal.trend}"
|
||||||
+ (
|
+ (
|
||||||
f" ({distance_200ma:+.1f}% from 200MA)"
|
f" ({distance_200ma:+.1f}% from 200MA)"
|
||||||
if distance_200ma
|
if distance_200ma
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if signal.notes:
|
||||||
|
lines.append(f" Notes: {'; '.join(signal.notes)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
return _to_text_response("\n".join(lines))
|
return _to_text_response("\n".join(lines))
|
||||||
@@ -515,51 +495,29 @@ def analyze_mean_reversion(
|
|||||||
lines.append(f"{ticker}: Insufficient price data\n")
|
lines.append(f"{ticker}: Insufficient price data\n")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
df = prices_to_df(prices)
|
signal = _technical_analyzer.analyze(ticker, prices_to_df(prices))
|
||||||
n = len(df)
|
deviation = (
|
||||||
|
((signal.current_price - signal.bollinger_mid) / signal.bollinger_mid)
|
||||||
|
* 100
|
||||||
|
if signal.bollinger_mid > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
# Bollinger Bands
|
if signal.current_price > signal.bollinger_upper > 0:
|
||||||
window = min(20, n - 2)
|
|
||||||
df["SMA"] = df["close"].rolling(window=window).mean()
|
|
||||||
df["STD"] = df["close"].rolling(window=window).std()
|
|
||||||
df["Upper_Band"] = df["SMA"] + (2 * df["STD"])
|
|
||||||
df["Lower_Band"] = df["SMA"] - (2 * df["STD"])
|
|
||||||
|
|
||||||
# RSI
|
|
||||||
delta = df["close"].diff()
|
|
||||||
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
|
|
||||||
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
|
|
||||||
rs = gain / loss
|
|
||||||
df["RSI"] = 100 - (100 / (1 + rs))
|
|
||||||
|
|
||||||
current_price = _safe_float(df["close"].iloc[-1])
|
|
||||||
sma = _safe_float(df["SMA"].iloc[-1])
|
|
||||||
upper = _safe_float(df["Upper_Band"].iloc[-1])
|
|
||||||
lower = _safe_float(df["Lower_Band"].iloc[-1])
|
|
||||||
rsi = _safe_float(df["RSI"].iloc[-1])
|
|
||||||
deviation = (current_price - sma) / sma * 100
|
|
||||||
|
|
||||||
# Signal interpretation
|
|
||||||
if rsi > 70:
|
|
||||||
rsi_signal = "OVERBOUGHT"
|
|
||||||
elif rsi < 30:
|
|
||||||
rsi_signal = "OVERSOLD"
|
|
||||||
else:
|
|
||||||
rsi_signal = "NEUTRAL"
|
|
||||||
|
|
||||||
if current_price > upper:
|
|
||||||
bb_signal = "ABOVE UPPER BAND (potential sell)"
|
bb_signal = "ABOVE UPPER BAND (potential sell)"
|
||||||
elif current_price < lower:
|
elif 0 < signal.current_price < signal.bollinger_lower:
|
||||||
bb_signal = "BELOW LOWER BAND (potential buy)"
|
bb_signal = "BELOW LOWER BAND (potential buy)"
|
||||||
else:
|
else:
|
||||||
bb_signal = "WITHIN BANDS"
|
bb_signal = "WITHIN BANDS"
|
||||||
|
|
||||||
lines.append(f"{ticker}: ${current_price:.2f}")
|
lines.append(f"{ticker}: ${signal.current_price:.2f}")
|
||||||
lines.append(
|
lines.append(
|
||||||
f" Bollinger: Lower ${lower:.2f} | SMA ${sma:.2f} | Upper ${upper:.2f}",
|
f" Bollinger: Lower ${signal.bollinger_lower:.2f} | Mid ${signal.bollinger_mid:.2f} | Upper ${signal.bollinger_upper:.2f}",
|
||||||
)
|
)
|
||||||
lines.append(f" Position: {bb_signal}")
|
lines.append(f" Position: {bb_signal}")
|
||||||
lines.append(f" RSI: {rsi:.1f} -> {rsi_signal}")
|
lines.append(
|
||||||
|
f" RSI: {signal.rsi14:.1f} -> {signal.mean_reversion_signal}",
|
||||||
|
)
|
||||||
lines.append(f" Price Deviation from SMA: {deviation:+.1f}%")
|
lines.append(f" Price Deviation from SMA: {deviation:+.1f}%")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -602,61 +560,30 @@ def analyze_momentum(
|
|||||||
lines.append(f"{ticker}: Insufficient price data\n")
|
lines.append(f"{ticker}: Insufficient price data\n")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
df = prices_to_df(prices)
|
signal = _technical_analyzer.analyze(ticker, prices_to_df(prices))
|
||||||
n = len(df)
|
|
||||||
df["returns"] = df["close"].pct_change()
|
|
||||||
|
|
||||||
# Adaptive periods
|
avg_mom = (
|
||||||
short_p = min(5, n // 3)
|
signal.momentum_5d_pct
|
||||||
med_p = min(10, n // 2)
|
+ signal.momentum_10d_pct
|
||||||
long_p = min(20, n - 2)
|
+ signal.momentum_20d_pct
|
||||||
|
) / 3
|
||||||
current_price = _safe_float(df["close"].iloc[-1])
|
|
||||||
mom_5 = (
|
|
||||||
_safe_float(
|
|
||||||
(df["close"].iloc[-1] / df["close"].iloc[-short_p - 1] - 1)
|
|
||||||
* 100,
|
|
||||||
)
|
|
||||||
if n > short_p
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
mom_10 = (
|
|
||||||
_safe_float(
|
|
||||||
(df["close"].iloc[-1] / df["close"].iloc[-med_p - 1] - 1)
|
|
||||||
* 100,
|
|
||||||
)
|
|
||||||
if n > med_p
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
mom_20 = (
|
|
||||||
_safe_float(
|
|
||||||
(df["close"].iloc[-1] / df["close"].iloc[-long_p - 1] - 1)
|
|
||||||
* 100,
|
|
||||||
)
|
|
||||||
if n > long_p
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
volatility = _safe_float(
|
|
||||||
df["returns"].tail(20).std() * np.sqrt(252) * 100,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Overall momentum signal
|
|
||||||
avg_mom = (mom_5 + mom_10 + mom_20) / 3
|
|
||||||
if avg_mom > 2:
|
if avg_mom > 2:
|
||||||
signal = "STRONG BULLISH"
|
signal_text = "STRONG BULLISH"
|
||||||
elif avg_mom > 0:
|
elif avg_mom > 0:
|
||||||
signal = "BULLISH"
|
signal_text = "BULLISH"
|
||||||
elif avg_mom > -2:
|
elif avg_mom > -2:
|
||||||
signal = "BEARISH"
|
signal_text = "BEARISH"
|
||||||
else:
|
else:
|
||||||
signal = "STRONG BEARISH"
|
signal_text = "STRONG BEARISH"
|
||||||
|
|
||||||
lines.append(f"{ticker}: ${current_price:.2f}")
|
lines.append(f"{ticker}: ${signal.current_price:.2f}")
|
||||||
lines.append(
|
lines.append(
|
||||||
f" 5-day: {mom_5:+.1f}% | 10-day: {mom_10:+.1f}% | 20-day: {mom_20:+.1f}%",
|
f" 5-day: {signal.momentum_5d_pct:+.1f}% | 10-day: {signal.momentum_10d_pct:+.1f}% | 20-day: {signal.momentum_20d_pct:+.1f}%",
|
||||||
)
|
)
|
||||||
lines.append(f" Volatility (annualized): {volatility:.1f}%")
|
lines.append(
|
||||||
lines.append(f" Overall: {signal}")
|
f" Volatility (annualized): {signal.annualized_volatility_pct:.1f}%",
|
||||||
|
)
|
||||||
|
lines.append(f" Overall: {signal_text}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
return _to_text_response("\n".join(lines))
|
return _to_text_response("\n".join(lines))
|
||||||
@@ -699,38 +626,26 @@ def analyze_volatility(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
df = prices_to_df(prices)
|
df = prices_to_df(prices)
|
||||||
n = len(df)
|
signal = _technical_analyzer.analyze(ticker, df)
|
||||||
df["returns"] = df["close"].pct_change()
|
returns = df["close"].pct_change()
|
||||||
|
short_w = min(10, max(1, len(df) - 1))
|
||||||
# Adaptive windows
|
med_w = min(20, max(1, len(df) - 1))
|
||||||
short_w = min(10, n // 2)
|
long_w = min(60, max(1, len(df) - 1))
|
||||||
med_w = min(20, n - 2)
|
|
||||||
long_w = min(60, n - 1) if n > 30 else med_w
|
|
||||||
|
|
||||||
current_price = _safe_float(df["close"].iloc[-1])
|
|
||||||
vol_10 = _safe_float(
|
vol_10 = _safe_float(
|
||||||
df["returns"].tail(short_w).std() * np.sqrt(252) * 100,
|
returns.tail(short_w).std() * np.sqrt(252) * 100,
|
||||||
)
|
)
|
||||||
vol_20 = _safe_float(
|
vol_20 = _safe_float(
|
||||||
df["returns"].tail(med_w).std() * np.sqrt(252) * 100,
|
returns.tail(med_w).std() * np.sqrt(252) * 100,
|
||||||
)
|
)
|
||||||
vol_60 = _safe_float(
|
vol_60 = _safe_float(
|
||||||
df["returns"].tail(long_w).std() * np.sqrt(252) * 100,
|
returns.tail(long_w).std() * np.sqrt(252) * 100,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Risk assessment
|
lines.append(f"{ticker}: ${signal.current_price:.2f}")
|
||||||
if vol_20 > 50:
|
|
||||||
risk = "HIGH RISK"
|
|
||||||
elif vol_20 > 25:
|
|
||||||
risk = "MODERATE RISK"
|
|
||||||
else:
|
|
||||||
risk = "LOW RISK"
|
|
||||||
|
|
||||||
lines.append(f"{ticker}: ${current_price:.2f}")
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f" 10-day Vol: {vol_10:.1f}% | 20-day Vol: {vol_20:.1f}% | 60-day Vol: {vol_60:.1f}%",
|
f" 10-day Vol: {vol_10:.1f}% | 20-day Vol: {vol_20:.1f}% | 60-day Vol: {vol_60:.1f}%",
|
||||||
)
|
)
|
||||||
lines.append(f" Risk Level: {risk}")
|
lines.append(f" Risk Level: {signal.risk_level}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
return _to_text_response("\n".join(lines))
|
return _to_text_response("\n".join(lines))
|
||||||
|
|||||||
@@ -1,43 +1,26 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# flake8: noqa: E501
|
# flake8: noqa: E501
|
||||||
# pylint: disable=C0301
|
# pylint: disable=C0301
|
||||||
"""
|
"""Data fetching tools backed by the unified provider router."""
|
||||||
Data fetching tools for financial data.
|
|
||||||
|
|
||||||
All functions use centralized data source configuration from data_config.py.
|
|
||||||
The data source is automatically determined based on available API keys:
|
|
||||||
- Priority: FINNHUB_API_KEY > FINANCIAL_DATASETS_API_KEY
|
|
||||||
"""
|
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
|
||||||
|
|
||||||
import finnhub
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pandas_market_calendars as mcal
|
import pandas_market_calendars as mcal
|
||||||
import requests
|
from backend.data.provider_utils import normalize_symbol
|
||||||
|
|
||||||
from backend.config.data_config import (
|
|
||||||
get_config,
|
|
||||||
get_api_key,
|
|
||||||
)
|
|
||||||
from backend.data.cache import get_cache
|
from backend.data.cache import get_cache
|
||||||
|
from backend.data.provider_router import get_provider_router
|
||||||
from backend.data.schema import (
|
from backend.data.schema import (
|
||||||
CompanyFactsResponse,
|
|
||||||
CompanyNews,
|
CompanyNews,
|
||||||
CompanyNewsResponse,
|
|
||||||
FinancialMetrics,
|
FinancialMetrics,
|
||||||
FinancialMetricsResponse,
|
|
||||||
InsiderTrade,
|
InsiderTrade,
|
||||||
InsiderTradeResponse,
|
|
||||||
LineItem,
|
LineItem,
|
||||||
LineItemResponse,
|
|
||||||
Price,
|
Price,
|
||||||
PriceResponse,
|
|
||||||
)
|
)
|
||||||
from backend.utils.settlement import logger
|
from backend.utils.settlement import logger
|
||||||
|
|
||||||
# Global cache instance
|
# Global cache instance
|
||||||
_cache = get_cache()
|
_cache = get_cache()
|
||||||
|
_router = get_provider_router()
|
||||||
|
|
||||||
|
|
||||||
def get_last_tradeday(date: str) -> str:
|
def get_last_tradeday(date: str) -> str:
|
||||||
@@ -94,48 +77,6 @@ def get_last_tradeday(date: str) -> str:
|
|||||||
return prev_date.strftime("%Y-%m-%d")
|
return prev_date.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
def _make_api_request(
|
|
||||||
url: str,
|
|
||||||
headers: dict,
|
|
||||||
method: str = "GET",
|
|
||||||
json_data: dict = None,
|
|
||||||
max_retries: int = 3,
|
|
||||||
) -> requests.Response:
|
|
||||||
"""
|
|
||||||
Make an API request with rate limiting handling and moderate backoff.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: The URL to request
|
|
||||||
headers: Headers to include in the request
|
|
||||||
method: HTTP method (GET or POST)
|
|
||||||
json_data: JSON data for POST requests
|
|
||||||
max_retries: Maximum number of retries (default: 3)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
requests.Response: The response object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If the request fails with a non-429 error
|
|
||||||
"""
|
|
||||||
for attempt in range(max_retries + 1): # +1 for initial attempt
|
|
||||||
if method.upper() == "POST":
|
|
||||||
response = requests.post(url, headers=headers, json=json_data)
|
|
||||||
else:
|
|
||||||
response = requests.get(url, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code == 429 and attempt < max_retries:
|
|
||||||
# Linear backoff: 60s, 90s, 120s, 150s...
|
|
||||||
delay = 60 + (30 * attempt)
|
|
||||||
print(
|
|
||||||
f"Rate limited (429). Attempt {attempt + 1}/{max_retries + 1}. Waiting {delay}s before retrying...",
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Return the response (whether success, other errors, or final 429)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def get_prices(
|
def get_prices(
|
||||||
ticker: str,
|
ticker: str,
|
||||||
start_date: str,
|
start_date: str,
|
||||||
@@ -154,75 +95,19 @@ def get_prices(
|
|||||||
Returns:
|
Returns:
|
||||||
list[Price]: List of Price objects
|
list[Price]: List of Price objects
|
||||||
"""
|
"""
|
||||||
config = get_config()
|
ticker = normalize_symbol(ticker)
|
||||||
data_source = config.source
|
cached_sources = _router.price_sources()
|
||||||
api_key = config.api_key
|
for source in cached_sources:
|
||||||
|
cache_key = f"{ticker}_{start_date}_{end_date}_{source}"
|
||||||
|
if cached_data := _cache.get_prices(cache_key):
|
||||||
|
return [Price(**price) for price in cached_data]
|
||||||
|
|
||||||
# Create a cache key that includes all parameters to ensure exact matches
|
prices, data_source = _router.get_prices(ticker, start_date, end_date)
|
||||||
cache_key = f"{ticker}_{start_date}_{end_date}_{data_source}"
|
|
||||||
|
|
||||||
# Check cache first - simple exact match
|
|
||||||
if cached_data := _cache.get_prices(cache_key):
|
|
||||||
return [Price(**price) for price in cached_data]
|
|
||||||
|
|
||||||
prices = []
|
|
||||||
|
|
||||||
if data_source == "finnhub":
|
|
||||||
# Use Finnhub API
|
|
||||||
client = finnhub.Client(api_key=api_key)
|
|
||||||
|
|
||||||
# Convert dates to timestamps
|
|
||||||
start_timestamp = int(
|
|
||||||
datetime.datetime.strptime(start_date, "%Y-%m-%d").timestamp(),
|
|
||||||
)
|
|
||||||
end_timestamp = int(
|
|
||||||
(
|
|
||||||
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
|
||||||
+ datetime.timedelta(days=1)
|
|
||||||
).timestamp(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch candle data from Finnhub
|
|
||||||
candles = client.stock_candles(
|
|
||||||
ticker,
|
|
||||||
"D",
|
|
||||||
start_timestamp,
|
|
||||||
end_timestamp,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert to Price objects
|
|
||||||
for i in range(len(candles["t"])):
|
|
||||||
price = Price(
|
|
||||||
open=candles["o"][i],
|
|
||||||
close=candles["c"][i],
|
|
||||||
high=candles["h"][i],
|
|
||||||
low=candles["l"][i],
|
|
||||||
volume=int(candles["v"][i]),
|
|
||||||
time=datetime.datetime.fromtimestamp(candles["t"][i]).strftime(
|
|
||||||
"%Y-%m-%d",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
prices.append(price)
|
|
||||||
|
|
||||||
else: # financial_datasets
|
|
||||||
# Use Financial Datasets API
|
|
||||||
headers = {"X-API-KEY": api_key}
|
|
||||||
|
|
||||||
url = f"https://api.financialdatasets.ai/prices/?ticker={ticker}&interval=day&interval_multiplier=1&start_date={start_date}&end_date={end_date}"
|
|
||||||
response = _make_api_request(url, headers)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise ValueError(
|
|
||||||
f"Error fetching data: {ticker} - {response.status_code} - {response.text}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse response with Pydantic model
|
|
||||||
price_response = PriceResponse(**response.json())
|
|
||||||
prices = price_response.prices
|
|
||||||
|
|
||||||
if not prices:
|
if not prices:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Cache the results using the comprehensive cache key
|
cache_key = f"{ticker}_{start_date}_{end_date}_{data_source}"
|
||||||
_cache.set_prices(cache_key, [p.model_dump() for p in prices])
|
_cache.set_prices(cache_key, [p.model_dump() for p in prices])
|
||||||
return prices
|
return prices
|
||||||
|
|
||||||
@@ -247,119 +132,29 @@ def get_financial_metrics(
|
|||||||
Returns:
|
Returns:
|
||||||
list[FinancialMetrics]: List of financial metrics
|
list[FinancialMetrics]: List of financial metrics
|
||||||
"""
|
"""
|
||||||
config = get_config()
|
ticker = normalize_symbol(ticker)
|
||||||
data_source = config.source
|
for source in _router.api_sources():
|
||||||
api_key = config.api_key
|
cache_key = f"{ticker}_{period}_{end_date}_{limit}_{source}"
|
||||||
|
if cached_data := _cache.get_financial_metrics(cache_key):
|
||||||
|
return [FinancialMetrics(**metric) for metric in cached_data]
|
||||||
|
|
||||||
# Create a cache key that includes all parameters to ensure exact matches
|
financial_metrics, data_source = _router.get_financial_metrics(
|
||||||
cache_key = f"{ticker}_{period}_{end_date}_{limit}_{data_source}"
|
ticker=ticker,
|
||||||
|
end_date=end_date,
|
||||||
# Check cache first - simple exact match
|
period=period,
|
||||||
if cached_data := _cache.get_financial_metrics(cache_key):
|
limit=limit,
|
||||||
return [FinancialMetrics(**metric) for metric in cached_data]
|
)
|
||||||
|
|
||||||
financial_metrics = []
|
|
||||||
|
|
||||||
if data_source == "finnhub":
|
|
||||||
# Use Finnhub API - Basic Financials
|
|
||||||
client = finnhub.Client(api_key=api_key)
|
|
||||||
|
|
||||||
# Fetch basic financials from Finnhub
|
|
||||||
# metric='all' returns all available metrics
|
|
||||||
financials = client.company_basic_financials(ticker, "all")
|
|
||||||
|
|
||||||
if not financials or "metric" not in financials:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Finnhub returns {series: {...}, metric: {...}, metricType: ..., symbol: ...}
|
|
||||||
# We need to create a FinancialMetrics object from this
|
|
||||||
metric_data = financials.get("metric", {})
|
|
||||||
|
|
||||||
# Create a FinancialMetrics object with available data
|
|
||||||
metric = _map_finnhub_metrics(ticker, end_date, period, metric_data)
|
|
||||||
|
|
||||||
financial_metrics = [metric]
|
|
||||||
|
|
||||||
else: # financial_datasets
|
|
||||||
# Use Financial Datasets API
|
|
||||||
headers = {"X-API-KEY": api_key}
|
|
||||||
|
|
||||||
url = f"https://api.financialdatasets.ai/financial-metrics/?ticker={ticker}&report_period_lte={end_date}&limit={limit}&period={period}"
|
|
||||||
response = _make_api_request(url, headers)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise ValueError(
|
|
||||||
f"Error fetching data: {ticker} - {response.status_code} - {response.text}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse response with Pydantic model
|
|
||||||
metrics_response = FinancialMetricsResponse(**response.json())
|
|
||||||
financial_metrics = metrics_response.financial_metrics
|
|
||||||
|
|
||||||
if not financial_metrics:
|
if not financial_metrics:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Cache the results as dicts using the comprehensive cache key
|
cache_key = f"{ticker}_{period}_{end_date}_{limit}_{data_source}"
|
||||||
_cache.set_financial_metrics(
|
_cache.set_financial_metrics(
|
||||||
cache_key,
|
cache_key,
|
||||||
[m.model_dump() for m in financial_metrics],
|
[m.model_dump() for m in financial_metrics],
|
||||||
)
|
)
|
||||||
return financial_metrics
|
return financial_metrics
|
||||||
|
|
||||||
|
|
||||||
def _map_finnhub_metrics(
|
|
||||||
ticker: str,
|
|
||||||
end_date: str,
|
|
||||||
period: str,
|
|
||||||
metric_data: dict,
|
|
||||||
) -> FinancialMetrics:
|
|
||||||
"""Map Finnhub metric data to FinancialMetrics model."""
|
|
||||||
return FinancialMetrics(
|
|
||||||
ticker=ticker,
|
|
||||||
report_period=end_date,
|
|
||||||
period=period,
|
|
||||||
currency="USD",
|
|
||||||
market_cap=metric_data.get("marketCapitalization"),
|
|
||||||
enterprise_value=None,
|
|
||||||
price_to_earnings_ratio=metric_data.get("peBasicExclExtraTTM"),
|
|
||||||
price_to_book_ratio=metric_data.get("pbAnnual"),
|
|
||||||
price_to_sales_ratio=metric_data.get("psAnnual"),
|
|
||||||
enterprise_value_to_ebitda_ratio=None,
|
|
||||||
enterprise_value_to_revenue_ratio=None,
|
|
||||||
free_cash_flow_yield=None,
|
|
||||||
peg_ratio=None,
|
|
||||||
gross_margin=metric_data.get("grossMarginTTM"),
|
|
||||||
operating_margin=metric_data.get("operatingMarginTTM"),
|
|
||||||
net_margin=metric_data.get("netProfitMarginTTM"),
|
|
||||||
return_on_equity=metric_data.get("roeTTM"),
|
|
||||||
return_on_assets=metric_data.get("roaTTM"),
|
|
||||||
return_on_invested_capital=metric_data.get("roicTTM"),
|
|
||||||
asset_turnover=metric_data.get("assetTurnoverTTM"),
|
|
||||||
inventory_turnover=metric_data.get("inventoryTurnoverTTM"),
|
|
||||||
receivables_turnover=metric_data.get("receivablesTurnoverTTM"),
|
|
||||||
days_sales_outstanding=None,
|
|
||||||
operating_cycle=None,
|
|
||||||
working_capital_turnover=None,
|
|
||||||
current_ratio=metric_data.get("currentRatioAnnual"),
|
|
||||||
quick_ratio=metric_data.get("quickRatioAnnual"),
|
|
||||||
cash_ratio=None,
|
|
||||||
operating_cash_flow_ratio=None,
|
|
||||||
debt_to_equity=metric_data.get("totalDebt/totalEquityAnnual"),
|
|
||||||
debt_to_assets=None,
|
|
||||||
interest_coverage=None,
|
|
||||||
revenue_growth=metric_data.get("revenueGrowthTTMYoy"),
|
|
||||||
earnings_growth=None,
|
|
||||||
book_value_growth=None,
|
|
||||||
earnings_per_share_growth=metric_data.get("epsGrowthTTMYoy"),
|
|
||||||
free_cash_flow_growth=None,
|
|
||||||
operating_income_growth=None,
|
|
||||||
ebitda_growth=None,
|
|
||||||
payout_ratio=metric_data.get("payoutRatioAnnual"),
|
|
||||||
earnings_per_share=metric_data.get("epsBasicExclExtraItemsTTM"),
|
|
||||||
book_value_per_share=metric_data.get("bookValuePerShareAnnual"),
|
|
||||||
free_cash_flow_per_share=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def search_line_items(
|
def search_line_items(
|
||||||
ticker: str,
|
ticker: str,
|
||||||
line_items: list[str],
|
line_items: list[str],
|
||||||
@@ -373,123 +168,20 @@ def search_line_items(
|
|||||||
Returns empty list on API errors to allow graceful degradation.
|
Returns empty list on API errors to allow graceful degradation.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
api_key = get_api_key()
|
ticker = normalize_symbol(ticker)
|
||||||
headers = {"X-API-KEY": api_key}
|
return _router.search_line_items(
|
||||||
|
ticker=ticker,
|
||||||
url = "https://api.financialdatasets.ai/financials/search/line-items"
|
line_items=line_items,
|
||||||
body = {
|
end_date=end_date,
|
||||||
"tickers": [ticker],
|
period=period,
|
||||||
"line_items": line_items,
|
limit=limit,
|
||||||
"end_date": end_date,
|
|
||||||
"period": period,
|
|
||||||
"limit": limit,
|
|
||||||
}
|
|
||||||
response = _make_api_request(
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
method="POST",
|
|
||||||
json_data=body,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
logger.info(
|
|
||||||
f"Warning: Failed to fetch line items for {ticker}: "
|
|
||||||
f"{response.status_code} - {response.text}",
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
response_model = LineItemResponse(**data)
|
|
||||||
search_results = response_model.search_results
|
|
||||||
|
|
||||||
if not search_results:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return search_results[:limit]
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Warning: Exception while fetching line items for {ticker}: {str(e)}",
|
f"Warning: Exception while fetching line items for {ticker}: {str(e)}",
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _fetch_finnhub_insider_trades(
|
|
||||||
ticker: str,
|
|
||||||
start_date: str | None,
|
|
||||||
end_date: str,
|
|
||||||
limit: int,
|
|
||||||
api_key: str,
|
|
||||||
) -> list[InsiderTrade]:
|
|
||||||
"""Fetch insider trades from Finnhub API."""
|
|
||||||
client = finnhub.Client(api_key=api_key)
|
|
||||||
|
|
||||||
from_date = start_date or (
|
|
||||||
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
|
||||||
- datetime.timedelta(days=365)
|
|
||||||
).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
insider_data = client.stock_insider_transactions(
|
|
||||||
ticker,
|
|
||||||
from_date,
|
|
||||||
end_date,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not insider_data or "data" not in insider_data:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
_convert_finnhub_insider_trade(ticker, trade)
|
|
||||||
for trade in insider_data["data"][:limit]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_fd_insider_trades(
|
|
||||||
ticker: str,
|
|
||||||
start_date: str | None,
|
|
||||||
end_date: str,
|
|
||||||
limit: int,
|
|
||||||
api_key: str,
|
|
||||||
) -> list[InsiderTrade]:
|
|
||||||
"""Fetch insider trades from Financial Datasets API."""
|
|
||||||
headers = {"X-API-KEY": api_key}
|
|
||||||
all_trades = []
|
|
||||||
current_end_date = end_date
|
|
||||||
|
|
||||||
while True:
|
|
||||||
url = f"https://api.financialdatasets.ai/insider-trades/?ticker={ticker}&filing_date_lte={current_end_date}"
|
|
||||||
if start_date:
|
|
||||||
url += f"&filing_date_gte={start_date}"
|
|
||||||
url += f"&limit={limit}"
|
|
||||||
|
|
||||||
response = _make_api_request(url, headers)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise ValueError(
|
|
||||||
f"Error fetching data: {ticker} - {response.status_code} - {response.text}",
|
|
||||||
)
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
response_model = InsiderTradeResponse(**data)
|
|
||||||
insider_trades = response_model.insider_trades
|
|
||||||
|
|
||||||
if not insider_trades:
|
|
||||||
break
|
|
||||||
|
|
||||||
all_trades.extend(insider_trades)
|
|
||||||
|
|
||||||
if not start_date or len(insider_trades) < limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
current_end_date = min(
|
|
||||||
trade.filing_date for trade in insider_trades
|
|
||||||
).split("T")[0]
|
|
||||||
|
|
||||||
if current_end_date <= start_date:
|
|
||||||
break
|
|
||||||
|
|
||||||
return all_trades
|
|
||||||
|
|
||||||
|
|
||||||
def get_insider_trades(
|
def get_insider_trades(
|
||||||
ticker: str,
|
ticker: str,
|
||||||
end_date: str,
|
end_date: str,
|
||||||
@@ -497,133 +189,28 @@ def get_insider_trades(
|
|||||||
limit: int = 1000,
|
limit: int = 1000,
|
||||||
) -> list[InsiderTrade]:
|
) -> list[InsiderTrade]:
|
||||||
"""Fetch insider trades from cache or API."""
|
"""Fetch insider trades from cache or API."""
|
||||||
config = get_config()
|
ticker = normalize_symbol(ticker)
|
||||||
data_source = config.source
|
for source in _router.api_sources():
|
||||||
api_key = config.api_key
|
cache_key = (
|
||||||
|
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{source}"
|
||||||
|
)
|
||||||
|
if cached_data := _cache.get_insider_trades(cache_key):
|
||||||
|
return [InsiderTrade(**trade) for trade in cached_data]
|
||||||
|
|
||||||
cache_key = (
|
all_trades, data_source = _router.get_insider_trades(
|
||||||
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{data_source}"
|
ticker=ticker,
|
||||||
|
end_date=end_date,
|
||||||
|
start_date=start_date,
|
||||||
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cached_data := _cache.get_insider_trades(cache_key):
|
|
||||||
return [InsiderTrade(**trade) for trade in cached_data]
|
|
||||||
|
|
||||||
if data_source == "finnhub":
|
|
||||||
all_trades = _fetch_finnhub_insider_trades(
|
|
||||||
ticker,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
limit,
|
|
||||||
api_key,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
all_trades = _fetch_fd_insider_trades(
|
|
||||||
ticker,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
limit,
|
|
||||||
api_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not all_trades:
|
if not all_trades:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
_cache.set_insider_trades(
|
cache_key = f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{data_source}"
|
||||||
cache_key,
|
_cache.set_insider_trades(cache_key, [trade.model_dump() for trade in all_trades])
|
||||||
[trade.model_dump() for trade in all_trades],
|
|
||||||
)
|
|
||||||
return all_trades
|
return all_trades
|
||||||
|
|
||||||
|
|
||||||
def _fetch_finnhub_company_news(
|
|
||||||
ticker: str,
|
|
||||||
start_date: str | None,
|
|
||||||
end_date: str,
|
|
||||||
limit: int,
|
|
||||||
api_key: str,
|
|
||||||
) -> list[CompanyNews]:
|
|
||||||
"""Fetch company news from Finnhub API."""
|
|
||||||
client = finnhub.Client(api_key=api_key)
|
|
||||||
|
|
||||||
from_date = start_date or (
|
|
||||||
datetime.datetime.strptime(end_date, "%Y-%m-%d")
|
|
||||||
- datetime.timedelta(days=30)
|
|
||||||
).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
news_data = client.company_news(ticker, _from=from_date, to=end_date)
|
|
||||||
|
|
||||||
if not news_data:
|
|
||||||
return []
|
|
||||||
|
|
||||||
all_news = []
|
|
||||||
for news_item in news_data[:limit]:
|
|
||||||
company_news = CompanyNews(
|
|
||||||
ticker=ticker,
|
|
||||||
title=news_item.get("headline", ""),
|
|
||||||
related=news_item.get("related", ""),
|
|
||||||
source=news_item.get("source", ""),
|
|
||||||
date=(
|
|
||||||
datetime.datetime.fromtimestamp(
|
|
||||||
news_item.get("datetime", 0),
|
|
||||||
datetime.timezone.utc,
|
|
||||||
).strftime("%Y-%m-%d")
|
|
||||||
if news_item.get("datetime")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
url=news_item.get("url", ""),
|
|
||||||
summary=news_item.get("summary", ""),
|
|
||||||
category=news_item.get("category", ""),
|
|
||||||
)
|
|
||||||
all_news.append(company_news)
|
|
||||||
return all_news
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_fd_company_news(
|
|
||||||
ticker: str,
|
|
||||||
start_date: str | None,
|
|
||||||
end_date: str,
|
|
||||||
limit: int,
|
|
||||||
api_key: str,
|
|
||||||
) -> list[CompanyNews]:
|
|
||||||
"""Fetch company news from Financial Datasets API."""
|
|
||||||
headers = {"X-API-KEY": api_key}
|
|
||||||
all_news = []
|
|
||||||
current_end_date = end_date
|
|
||||||
|
|
||||||
while True:
|
|
||||||
url = f"https://api.financialdatasets.ai/news/?ticker={ticker}&end_date={current_end_date}"
|
|
||||||
if start_date:
|
|
||||||
url += f"&start_date={start_date}"
|
|
||||||
url += f"&limit={limit}"
|
|
||||||
|
|
||||||
response = _make_api_request(url, headers)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise ValueError(
|
|
||||||
f"Error fetching data: {ticker} - {response.status_code} - {response.text}",
|
|
||||||
)
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
response_model = CompanyNewsResponse(**data)
|
|
||||||
company_news = response_model.news
|
|
||||||
|
|
||||||
if not company_news:
|
|
||||||
break
|
|
||||||
|
|
||||||
all_news.extend(company_news)
|
|
||||||
|
|
||||||
if not start_date or len(company_news) < limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
current_end_date = min(
|
|
||||||
news.date for news in company_news if news.date is not None
|
|
||||||
).split("T")[0]
|
|
||||||
|
|
||||||
if current_end_date <= start_date:
|
|
||||||
break
|
|
||||||
|
|
||||||
return all_news
|
|
||||||
|
|
||||||
|
|
||||||
def get_company_news(
|
def get_company_news(
|
||||||
ticker: str,
|
ticker: str,
|
||||||
end_date: str,
|
end_date: str,
|
||||||
@@ -631,102 +218,49 @@ def get_company_news(
|
|||||||
limit: int = 1000,
|
limit: int = 1000,
|
||||||
) -> list[CompanyNews]:
|
) -> list[CompanyNews]:
|
||||||
"""Fetch company news from cache or API."""
|
"""Fetch company news from cache or API."""
|
||||||
config = get_config()
|
ticker = normalize_symbol(ticker)
|
||||||
data_source = config.source
|
for source in _router.api_sources():
|
||||||
api_key = config.api_key
|
cache_key = (
|
||||||
|
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{source}"
|
||||||
|
)
|
||||||
|
if cached_data := _cache.get_company_news(cache_key):
|
||||||
|
return [CompanyNews(**news) for news in cached_data]
|
||||||
|
|
||||||
cache_key = (
|
all_news, data_source = _router.get_company_news(
|
||||||
f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{data_source}"
|
ticker=ticker,
|
||||||
|
end_date=end_date,
|
||||||
|
start_date=start_date,
|
||||||
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cached_data := _cache.get_company_news(cache_key):
|
|
||||||
return [CompanyNews(**news) for news in cached_data]
|
|
||||||
|
|
||||||
if data_source == "finnhub":
|
|
||||||
all_news = _fetch_finnhub_company_news(
|
|
||||||
ticker,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
limit,
|
|
||||||
api_key,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
all_news = _fetch_fd_company_news(
|
|
||||||
ticker,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
limit,
|
|
||||||
api_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not all_news:
|
if not all_news:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
_cache.set_company_news(
|
cache_key = f"{ticker}_{start_date or 'none'}_{end_date}_{limit}_{data_source}"
|
||||||
cache_key,
|
_cache.set_company_news(cache_key, [news.model_dump() for news in all_news])
|
||||||
[news.model_dump() for news in all_news],
|
|
||||||
)
|
|
||||||
return all_news
|
return all_news
|
||||||
|
|
||||||
|
|
||||||
def _convert_finnhub_insider_trade(ticker: str, trade: dict) -> InsiderTrade:
|
|
||||||
"""Convert Finnhub insider trade format to InsiderTrade model."""
|
|
||||||
shares_after = trade.get("share", 0)
|
|
||||||
change = trade.get("change", 0)
|
|
||||||
|
|
||||||
return InsiderTrade(
|
|
||||||
ticker=ticker,
|
|
||||||
issuer=None,
|
|
||||||
name=trade.get("name", ""),
|
|
||||||
title=None,
|
|
||||||
is_board_director=None,
|
|
||||||
transaction_date=trade.get("transactionDate", ""),
|
|
||||||
transaction_shares=abs(change),
|
|
||||||
transaction_price_per_share=trade.get("transactionPrice", 0.0),
|
|
||||||
transaction_value=abs(change) * trade.get("transactionPrice", 0.0),
|
|
||||||
shares_owned_before_transaction=(
|
|
||||||
shares_after - change if shares_after and change else None
|
|
||||||
),
|
|
||||||
shares_owned_after_transaction=float(shares_after)
|
|
||||||
if shares_after
|
|
||||||
else None,
|
|
||||||
security_title=None,
|
|
||||||
filing_date=trade.get("filingDate", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_market_cap(ticker: str, end_date: str) -> float | None:
|
def get_market_cap(ticker: str, end_date: str) -> float | None:
|
||||||
"""Fetch market cap from the API. Finnhub values are converted from millions."""
|
"""Fetch market cap from the API. Finnhub values are converted from millions."""
|
||||||
config = get_config()
|
ticker = normalize_symbol(ticker)
|
||||||
data_source = config.source
|
|
||||||
api_key = config.api_key
|
|
||||||
|
|
||||||
# For today's date, use company facts API
|
def _metrics_lookup(symbol: str, date: str):
|
||||||
if end_date == datetime.datetime.now().strftime("%Y-%m-%d"):
|
for source in _router.api_sources():
|
||||||
headers = {"X-API-KEY": api_key}
|
cache_key = f"{symbol}_ttm_{date}_10_{source}"
|
||||||
url = (
|
if cached_data := _cache.get_financial_metrics(cache_key):
|
||||||
f"https://api.financialdatasets.ai/company/facts/?ticker={ticker}"
|
return [FinancialMetrics(**metric) for metric in cached_data], source
|
||||||
|
return _router.get_financial_metrics(
|
||||||
|
ticker=symbol,
|
||||||
|
end_date=date,
|
||||||
|
period="ttm",
|
||||||
|
limit=10,
|
||||||
)
|
)
|
||||||
response = _make_api_request(url, headers)
|
|
||||||
if response.status_code != 200:
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
response_model = CompanyFactsResponse(**data)
|
|
||||||
return response_model.company_facts.market_cap
|
|
||||||
|
|
||||||
financial_metrics = get_financial_metrics(ticker, end_date)
|
|
||||||
if not financial_metrics:
|
|
||||||
return None
|
|
||||||
|
|
||||||
market_cap = financial_metrics[0].market_cap
|
|
||||||
if not market_cap:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Finnhub returns market cap in millions
|
|
||||||
if data_source == "finnhub":
|
|
||||||
market_cap = market_cap * 1_000_000
|
|
||||||
|
|
||||||
|
market_cap, _ = _router.get_market_cap(
|
||||||
|
ticker=ticker,
|
||||||
|
end_date=end_date,
|
||||||
|
metrics_lookup=_metrics_lookup,
|
||||||
|
)
|
||||||
return market_cap
|
return market_cap
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
193
backend/tools/technical_signals.py
Normal file
193
backend/tools/technical_signals.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Structured technical signal analysis used by technical tools."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TechnicalSignal:
|
||||||
|
"""Structured technical analysis result for one ticker."""
|
||||||
|
|
||||||
|
ticker: str
|
||||||
|
current_price: float = 0.0
|
||||||
|
ma5: float = 0.0
|
||||||
|
ma10: float = 0.0
|
||||||
|
ma20: float = 0.0
|
||||||
|
ma50: float = 0.0
|
||||||
|
ma200: Optional[float] = None
|
||||||
|
bias_ma5_pct: float = 0.0
|
||||||
|
momentum_5d_pct: float = 0.0
|
||||||
|
momentum_10d_pct: float = 0.0
|
||||||
|
momentum_20d_pct: float = 0.0
|
||||||
|
annualized_volatility_pct: float = 0.0
|
||||||
|
rsi14: float = 50.0
|
||||||
|
macd: float = 0.0
|
||||||
|
macd_signal: float = 0.0
|
||||||
|
bollinger_upper: float = 0.0
|
||||||
|
bollinger_mid: float = 0.0
|
||||||
|
bollinger_lower: float = 0.0
|
||||||
|
trend: str = "NEUTRAL"
|
||||||
|
mean_reversion_signal: str = "NEUTRAL"
|
||||||
|
risk_level: str = "MODERATE RISK"
|
||||||
|
notes: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_summary(self) -> Dict[str, object]:
|
||||||
|
"""Compact dict for logs/tests."""
|
||||||
|
return {
|
||||||
|
"ticker": self.ticker,
|
||||||
|
"trend": self.trend,
|
||||||
|
"mean_reversion_signal": self.mean_reversion_signal,
|
||||||
|
"risk_level": self.risk_level,
|
||||||
|
"current_price": self.current_price,
|
||||||
|
"rsi14": self.rsi14,
|
||||||
|
"annualized_volatility_pct": self.annualized_volatility_pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StockTechnicalAnalyzer:
|
||||||
|
"""Lightweight technical analyzer adapted for EvoTraders tools."""
|
||||||
|
|
||||||
|
def analyze(self, ticker: str, df: pd.DataFrame) -> TechnicalSignal:
|
||||||
|
"""Analyze one ticker from OHLC price history."""
|
||||||
|
result = TechnicalSignal(ticker=ticker)
|
||||||
|
if df is None or df.empty or len(df) < 5:
|
||||||
|
result.notes.append("Insufficient price data")
|
||||||
|
return result
|
||||||
|
|
||||||
|
frame = df.sort_values("time").reset_index(drop=True).copy()
|
||||||
|
frame["close"] = pd.to_numeric(frame["close"], errors="coerce")
|
||||||
|
frame["returns"] = frame["close"].pct_change()
|
||||||
|
|
||||||
|
for window in (5, 10, 20, 50, 200):
|
||||||
|
frame[f"MA_{window}"] = frame["close"].rolling(window).mean()
|
||||||
|
|
||||||
|
frame["EMA_12"] = frame["close"].ewm(span=12, adjust=False).mean()
|
||||||
|
frame["EMA_26"] = frame["close"].ewm(span=26, adjust=False).mean()
|
||||||
|
frame["MACD"] = frame["EMA_12"] - frame["EMA_26"]
|
||||||
|
frame["MACD_SIGNAL"] = (
|
||||||
|
frame["MACD"].ewm(span=9, adjust=False).mean()
|
||||||
|
)
|
||||||
|
|
||||||
|
delta = frame["close"].diff()
|
||||||
|
gain = delta.where(delta > 0, 0.0)
|
||||||
|
loss = -delta.where(delta < 0, 0.0)
|
||||||
|
avg_gain = gain.rolling(14).mean()
|
||||||
|
avg_loss = loss.rolling(14).mean()
|
||||||
|
rs = avg_gain / avg_loss.replace(0, pd.NA)
|
||||||
|
frame["RSI_14"] = 100 - (100 / (1 + rs))
|
||||||
|
|
||||||
|
frame["BB_MID"] = frame["close"].rolling(20).mean()
|
||||||
|
frame["BB_STD"] = frame["close"].rolling(20).std()
|
||||||
|
frame["BB_UPPER"] = frame["BB_MID"] + 2 * frame["BB_STD"]
|
||||||
|
frame["BB_LOWER"] = frame["BB_MID"] - 2 * frame["BB_STD"]
|
||||||
|
|
||||||
|
latest = frame.iloc[-1]
|
||||||
|
result.current_price = _safe_number(latest["close"])
|
||||||
|
result.ma5 = _safe_number(latest["MA_5"])
|
||||||
|
result.ma10 = _safe_number(latest["MA_10"])
|
||||||
|
result.ma20 = _safe_number(latest["MA_20"])
|
||||||
|
result.ma50 = _safe_number(latest["MA_50"])
|
||||||
|
result.ma200 = _safe_optional(latest["MA_200"])
|
||||||
|
result.bias_ma5_pct = _percent_gap(result.current_price, result.ma5)
|
||||||
|
result.momentum_5d_pct = _lookback_return(frame["close"], 5)
|
||||||
|
result.momentum_10d_pct = _lookback_return(frame["close"], 10)
|
||||||
|
result.momentum_20d_pct = _lookback_return(frame["close"], 20)
|
||||||
|
result.annualized_volatility_pct = _safe_number(
|
||||||
|
frame["returns"].tail(20).std() * (252**0.5) * 100,
|
||||||
|
)
|
||||||
|
result.rsi14 = _safe_number(latest["RSI_14"], default=50.0)
|
||||||
|
result.macd = _safe_number(latest["MACD"])
|
||||||
|
result.macd_signal = _safe_number(latest["MACD_SIGNAL"])
|
||||||
|
result.bollinger_mid = _safe_number(latest["BB_MID"])
|
||||||
|
result.bollinger_upper = _safe_number(latest["BB_UPPER"])
|
||||||
|
result.bollinger_lower = _safe_number(latest["BB_LOWER"])
|
||||||
|
result.trend = _classify_trend(result)
|
||||||
|
result.mean_reversion_signal = _classify_mean_reversion(result)
|
||||||
|
result.risk_level = _classify_risk(result.annualized_volatility_pct)
|
||||||
|
result.notes = _build_notes(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_number(value, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
if pd.isna(value):
|
||||||
|
return default
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_optional(value) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if pd.isna(value):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lookback_return(series: pd.Series, lookback: int) -> float:
|
||||||
|
if len(series) <= lookback:
|
||||||
|
return 0.0
|
||||||
|
base = _safe_number(series.iloc[-lookback - 1])
|
||||||
|
latest = _safe_number(series.iloc[-1])
|
||||||
|
if base <= 0:
|
||||||
|
return 0.0
|
||||||
|
return ((latest / base) - 1) * 100
|
||||||
|
|
||||||
|
|
||||||
|
def _percent_gap(value: float, anchor: float) -> float:
|
||||||
|
if anchor <= 0:
|
||||||
|
return 0.0
|
||||||
|
return ((value - anchor) / anchor) * 100
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_trend(result: TechnicalSignal) -> str:
|
||||||
|
bullish_stack = (
|
||||||
|
result.current_price >= result.ma5 >= result.ma10 >= result.ma20 > 0
|
||||||
|
)
|
||||||
|
if bullish_stack and result.macd >= result.macd_signal:
|
||||||
|
return "STRONG BULLISH"
|
||||||
|
if bullish_stack:
|
||||||
|
return "BULLISH"
|
||||||
|
if result.current_price < result.ma20 and result.macd < result.macd_signal:
|
||||||
|
return "BEARISH"
|
||||||
|
return "NEUTRAL"
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_mean_reversion(result: TechnicalSignal) -> str:
|
||||||
|
if result.rsi14 <= 30 or (
|
||||||
|
result.bollinger_lower > 0
|
||||||
|
and result.current_price <= result.bollinger_lower
|
||||||
|
):
|
||||||
|
return "OVERSOLD"
|
||||||
|
if result.rsi14 >= 70 or (
|
||||||
|
result.bollinger_upper > 0
|
||||||
|
and result.current_price >= result.bollinger_upper
|
||||||
|
):
|
||||||
|
return "OVERBOUGHT"
|
||||||
|
return "NEUTRAL"
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_risk(volatility_pct: float) -> str:
|
||||||
|
if volatility_pct > 50:
|
||||||
|
return "HIGH RISK"
|
||||||
|
if volatility_pct > 25:
|
||||||
|
return "MODERATE RISK"
|
||||||
|
return "LOW RISK"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_notes(result: TechnicalSignal) -> List[str]:
|
||||||
|
notes = []
|
||||||
|
if abs(result.bias_ma5_pct) > 5:
|
||||||
|
notes.append("Price extended from MA5")
|
||||||
|
if result.macd > result.macd_signal:
|
||||||
|
notes.append("MACD supports upside momentum")
|
||||||
|
if result.mean_reversion_signal == "OVERSOLD":
|
||||||
|
notes.append("Potential rebound setup")
|
||||||
|
if result.mean_reversion_signal == "OVERBOUGHT":
|
||||||
|
notes.append("Potential pullback setup")
|
||||||
|
return notes
|
||||||
@@ -38,6 +38,7 @@ class TerminalDashboard:
|
|||||||
self.end_date = ""
|
self.end_date = ""
|
||||||
self.tickers: List[str] = []
|
self.tickers: List[str] = []
|
||||||
self.initial_cash = 100000.0
|
self.initial_cash = 100000.0
|
||||||
|
self.data_sources: Dict[str, Any] = {}
|
||||||
|
|
||||||
# Trading state
|
# Trading state
|
||||||
self.current_date = "-"
|
self.current_date = "-"
|
||||||
@@ -72,6 +73,7 @@ class TerminalDashboard:
|
|||||||
end_date: str = "",
|
end_date: str = "",
|
||||||
tickers: List[str] = None,
|
tickers: List[str] = None,
|
||||||
initial_cash: float = 100000.0,
|
initial_cash: float = 100000.0,
|
||||||
|
data_sources: Dict[str, Any] = None,
|
||||||
):
|
):
|
||||||
"""Set configuration state"""
|
"""Set configuration state"""
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -88,6 +90,7 @@ class TerminalDashboard:
|
|||||||
self.end_date = end_date
|
self.end_date = end_date
|
||||||
self.tickers = tickers or []
|
self.tickers = tickers or []
|
||||||
self.initial_cash = initial_cash
|
self.initial_cash = initial_cash
|
||||||
|
self.data_sources = data_sources or {}
|
||||||
self.total_value = initial_cash
|
self.total_value = initial_cash
|
||||||
self.cash = initial_cash
|
self.cash = initial_cash
|
||||||
|
|
||||||
@@ -114,6 +117,11 @@ class TerminalDashboard:
|
|||||||
left.add_row(f"[bold]Mode:[/bold] {mode_str}")
|
left.add_row(f"[bold]Mode:[/bold] {mode_str}")
|
||||||
left.add_row(f"[dim]Config:[/dim] {self.config_name}")
|
left.add_row(f"[dim]Config:[/dim] {self.config_name}")
|
||||||
left.add_row(f"[dim]Server:[/dim] {self.host}:{self.port}")
|
left.add_row(f"[dim]Server:[/dim] {self.host}:{self.port}")
|
||||||
|
preferred_sources = self.data_sources.get("preferred", [])
|
||||||
|
if preferred_sources:
|
||||||
|
left.add_row(
|
||||||
|
f"[dim]Data:[/dim] {' -> '.join(preferred_sources)}",
|
||||||
|
)
|
||||||
|
|
||||||
if self.mode == "live" and self.nyse_time:
|
if self.mode == "live" and self.nyse_time:
|
||||||
left.add_row(f"[dim]NYSE:[/dim] {self.nyse_time[:19]}")
|
left.add_row(f"[dim]NYSE:[/dim] {self.nyse_time[:19]}")
|
||||||
@@ -265,6 +273,7 @@ class TerminalDashboard:
|
|||||||
trades: List[Dict] = None,
|
trades: List[Dict] = None,
|
||||||
days_completed: int = None,
|
days_completed: int = None,
|
||||||
days_total: int = None,
|
days_total: int = None,
|
||||||
|
data_sources: Dict[str, Any] = None,
|
||||||
):
|
):
|
||||||
"""Update dashboard state and refresh display"""
|
"""Update dashboard state and refresh display"""
|
||||||
if date:
|
if date:
|
||||||
@@ -297,6 +306,8 @@ class TerminalDashboard:
|
|||||||
self.holdings = holdings
|
self.holdings = holdings
|
||||||
if trades is not None:
|
if trades is not None:
|
||||||
self.trades = trades
|
self.trades = trades
|
||||||
|
if data_sources is not None:
|
||||||
|
self.data_sources = data_sources
|
||||||
|
|
||||||
if self.live:
|
if self.live:
|
||||||
self.live.update(self._build_panel())
|
self.live.update(self._build_panel())
|
||||||
|
|||||||
42
deploy/nginx/evotraders.cillinn.com.conf
Normal file
42
deploy/nginx/evotraders.cillinn.com.conf
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name evotraders.cillinn.com;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/evotraders/current;
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name evotraders.cillinn.com;
|
||||||
|
|
||||||
|
root /var/www/evotraders/current;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/evotraders.cillinn.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/evotraders.cillinn.com/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:8765;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
deploy/nginx/evotraders.cillinn.com.http.conf
Normal file
15
deploy/nginx/evotraders.cillinn.com.http.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name evotraders.cillinn.com;
|
||||||
|
|
||||||
|
root /var/www/evotraders/current;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
deploy/systemd/evotraders.service
Normal file
14
deploy/systemd/evotraders.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=EvoTraders Production Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/root/code/evotraders
|
||||||
|
ExecStart=/root/code/evotraders/scripts/run_prod.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -9,7 +9,8 @@ TICKERS=AAPL,MSFT,GOOGL,NVDA,TSLA,META,AMZN
|
|||||||
# finnhub: https://finnhub.io/register
|
# finnhub: https://finnhub.io/register
|
||||||
# financial datasets: https://www.financialdatasets.ai/
|
# financial datasets: https://www.financialdatasets.ai/
|
||||||
|
|
||||||
FIN_DATA_SOURCE = #finnhub or financial_datasets | finnhub 或 financial_datasets
|
FIN_DATA_SOURCE = # Preferred source: finnhub / financial_datasets / yfinance / local_csv | 首选数据源
|
||||||
|
ENABLED_DATA_SOURCES = # Optional allowlist, comma-separated, e.g. yfinance,finnhub,financial_datasets,local_csv | 可启用数据源列表
|
||||||
FINANCIAL_DATASETS_API_KEY= #required | 必填
|
FINANCIAL_DATASETS_API_KEY= #required | 必填
|
||||||
FINNHUB_API_KEY= #optional | 可选
|
FINNHUB_API_KEY= #optional | 可选
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default function LiveTradingApp() {
|
|||||||
const [serverMode, setServerMode] = useState(null); // 'live' | 'backtest' | null
|
const [serverMode, setServerMode] = useState(null); // 'live' | 'backtest' | null
|
||||||
const [marketStatus, setMarketStatus] = useState(null); // { status, status_text, ... }
|
const [marketStatus, setMarketStatus] = useState(null); // { status, status_text, ... }
|
||||||
const [virtualTime, setVirtualTime] = useState(null); // Virtual time from server (for mock mode)
|
const [virtualTime, setVirtualTime] = useState(null); // Virtual time from server (for mock mode)
|
||||||
|
const [dataSources, setDataSources] = useState(null);
|
||||||
|
|
||||||
const clientRef = useRef(null);
|
const clientRef = useRef(null);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
@@ -290,6 +291,9 @@ export default function LiveTradingApp() {
|
|||||||
if (state.server_mode) {
|
if (state.server_mode) {
|
||||||
setServerMode(state.server_mode);
|
setServerMode(state.server_mode);
|
||||||
}
|
}
|
||||||
|
if (state.data_sources) {
|
||||||
|
setDataSources(state.data_sources);
|
||||||
|
}
|
||||||
// 检查是否是mock模式
|
// 检查是否是mock模式
|
||||||
const isMockMode = state.is_mock_mode === true;
|
const isMockMode = state.is_mock_mode === true;
|
||||||
if (state.market_status) {
|
if (state.market_status) {
|
||||||
@@ -365,6 +369,12 @@ export default function LiveTradingApp() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
data_sources_update: (e) => {
|
||||||
|
if (e.data_sources) {
|
||||||
|
setDataSources(e.data_sources);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Real-time price updates
|
// Real-time price updates
|
||||||
price_update: (e) => {
|
price_update: (e) => {
|
||||||
try {
|
try {
|
||||||
@@ -864,6 +874,14 @@ export default function LiveTradingApp() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{dataSources?.last_success?.prices && (
|
||||||
|
<>
|
||||||
|
<span className="status-sep">·</span>
|
||||||
|
<span className="market-text backtest">
|
||||||
|
DATA {String(dataSources.last_success.prices).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="status-sep">·</span>
|
<span className="status-sep">·</span>
|
||||||
<span className="time-text">{lastUpdate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
<span className="time-text">{lastUpdate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Header from './Header.jsx';
|
|||||||
|
|
||||||
export default function AboutModal({ onClose }) {
|
export default function AboutModal({ onClose }) {
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const [language, setLanguage] = useState('en'); // 'en' or 'zh'
|
const [language] = useState('zh');
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setIsClosing(true);
|
setIsClosing(true);
|
||||||
@@ -188,79 +188,14 @@ export default function AboutModal({ onClose }) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={contentStyle} onClick={(e) => e.stopPropagation()}>
|
<div style={contentStyle} onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Language Switch */}
|
|
||||||
<div style={languageSwitchStyle}>
|
<div style={languageSwitchStyle}>
|
||||||
<span
|
<span
|
||||||
style={getLangStyle(language === 'zh')}
|
style={getLangStyle(true)}
|
||||||
onClick={() => setLanguage('zh')}
|
|
||||||
>
|
>
|
||||||
中文
|
中文
|
||||||
</span>
|
</span>
|
||||||
<span style={{ padding: '0 4px', color: '#999' }}>|</span>
|
|
||||||
<span
|
|
||||||
style={getLangStyle(language === 'en')}
|
|
||||||
onClick={() => setLanguage('en')}
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{language === 'en' ? (
|
|
||||||
// English Content
|
|
||||||
<>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '40px', fontSize: '15px', fontWeight: 600 }}>
|
|
||||||
{content.en.question}
|
|
||||||
<span style={highlight}>{content.en.questionHighlight}</span>
|
|
||||||
{content.en.questionEnd}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '30px' }}>
|
|
||||||
{content.en.intro}
|
|
||||||
<span style={highlight}>{content.en.introHighlight1}</span>
|
|
||||||
{content.en.introContinue}
|
|
||||||
<span style={highlight}>{content.en.introHighlight2}</span>
|
|
||||||
{content.en.introContinue2}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '25px' }}>
|
|
||||||
<span style={highlight}>{content.en.point1Highlight}</span>
|
|
||||||
{content.en.point1}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '25px' }}>
|
|
||||||
<span style={highlight}>{content.en.point2Highlight}</span>
|
|
||||||
{content.en.point2}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '40px' }}>
|
|
||||||
<span style={highlight}>{content.en.point3Highlight}</span>
|
|
||||||
{content.en.point3}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '25px', opacity: 0.7 }}>
|
|
||||||
Everything is fully open-source. Built on{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/agentscope-ai"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={linkStyle}
|
|
||||||
>
|
|
||||||
AgentScope
|
|
||||||
</a>
|
|
||||||
, using{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/agentscope-ai/ReMe"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={linkStyle}
|
|
||||||
>
|
|
||||||
ReMe
|
|
||||||
</a>
|
|
||||||
{' '}for memory management.
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Chinese Content
|
// Chinese Content
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: '30px' }}>
|
<div style={{ marginBottom: '30px' }}>
|
||||||
@@ -309,7 +244,7 @@ export default function AboutModal({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '10px', opacity: 0.7 }}>
|
<div style={{ marginBottom: '10px', opacity: 0.7 }}>
|
||||||
我们已经在github上开源。
|
我们已经在 GitHub 上开源。
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: '25px', opacity: 0.7 }}>
|
<div style={{ marginBottom: '25px', opacity: 0.7 }}>
|
||||||
EvoTraders 基于{' '}
|
EvoTraders 基于{' '}
|
||||||
@@ -337,7 +272,6 @@ export default function AboutModal({ onClose }) {
|
|||||||
你可以在此找到完整项目与示例:
|
你可以在此找到完整项目与示例:
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginTop: '40px' }}>
|
<div style={{ marginTop: '40px' }}>
|
||||||
<a
|
<a
|
||||||
@@ -351,11 +285,10 @@ export default function AboutModal({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={closeHintStyle} onClick={handleClose}>
|
<div style={closeHintStyle} onClick={handleClose}>
|
||||||
{language === 'en' ? 'Click here to close' : '点击此处关闭'}
|
点击此处关闭
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
</div>
|
</div>
|
||||||
{rankMedal && !isPortfolioManager && (
|
{rankMedal && !isPortfolioManager && (
|
||||||
<div style={{ fontSize: 18 }}>
|
<div style={{ fontSize: 18 }}>
|
||||||
{rankMedal.emoji} Rank #{agent.rank}
|
{rankMedal.emoji} 第 {agent.rank} 名
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +188,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
cursor: 'help'
|
cursor: 'help'
|
||||||
}}
|
}}
|
||||||
title={`Model: ${agent.modelName}\nProvider: ${modelInfo.provider}`}>
|
title={`模型:${agent.modelName}\n提供方:${modelInfo.provider}`}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
@@ -272,7 +272,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
marginBottom: 2
|
marginBottom: 2
|
||||||
}}>
|
}}>
|
||||||
{overallWinRate != null ? `${(overallWinRate * 100).toFixed(1)}%` : 'N/A'}
|
{overallWinRate != null ? `${(overallWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -318,7 +318,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
lineHeight: 1
|
lineHeight: 1
|
||||||
}}>
|
}}>
|
||||||
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : 'N/A'}
|
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -355,7 +355,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
lineHeight: 1
|
lineHeight: 1
|
||||||
}}>
|
}}>
|
||||||
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : 'N/A'}
|
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -439,7 +439,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
color: '#555555'
|
color: '#555555'
|
||||||
}}>
|
}}>
|
||||||
{signal.date?.substring(5, 10) || 'N/A'}
|
{signal.date?.substring(5, 10) || '暂无'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: resultFontSize,
|
fontSize: resultFontSize,
|
||||||
@@ -514,4 +514,3 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -525,7 +525,7 @@ function MessageItem({ message, itemId, isHighlighted, getAgentModelInfo }) {
|
|||||||
|
|
||||||
const colors = message.agent === 'Memory' ? MESSAGE_COLORS.memory :
|
const colors = message.agent === 'Memory' ? MESSAGE_COLORS.memory :
|
||||||
getAgentColors(message.agentId, message.agent);
|
getAgentColors(message.agentId, message.agent);
|
||||||
const title = message.agent === 'Memory' ? '记忆' : message.agent || 'AGENT';
|
const title = message.agent === 'Memory' ? '记忆' : message.agent || '智能体';
|
||||||
|
|
||||||
const agentModelData = message.agentId && getAgentModelInfo ?
|
const agentModelData = message.agentId && getAgentModelInfo ?
|
||||||
getAgentModelInfo(message.agentId) :
|
getAgentModelInfo(message.agentId) :
|
||||||
|
|||||||
@@ -13,24 +13,24 @@ export default function PerformanceView({ leaderboard }) {
|
|||||||
{/* Agent Performance Section */}
|
{/* Agent Performance Section */}
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2 className="section-title">Agent Performance - Signal Accuracy</h2>
|
<h2 className="section-title">分析师表现 - 信号准确率</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rankedAgents.length === 0 ? (
|
{rankedAgents.length === 0 ? (
|
||||||
<div className="empty-state">No leaderboard data available</div>
|
<div className="empty-state">暂无排行榜数据</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Rank</th>
|
<th>排名</th>
|
||||||
<th>Agent</th>
|
<th>分析师</th>
|
||||||
<th>Win Rate</th>
|
<th>胜率</th>
|
||||||
<th>Bull Signals</th>
|
<th>看涨信号</th>
|
||||||
<th>Bull Win Rate</th>
|
<th>看涨胜率</th>
|
||||||
<th>Bear Signals</th>
|
<th>看跌信号</th>
|
||||||
<th>Bear Win Rate</th>
|
<th>看跌胜率</th>
|
||||||
<th>Total Signals</th>
|
<th>总信号数</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -66,27 +66,27 @@ export default function PerformanceView({ leaderboard }) {
|
|||||||
<div style={{ fontSize: 10, color: '#666666' }}>{agent.role}</div>
|
<div style={{ fontSize: 10, color: '#666666' }}>{agent.role}</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontWeight: 700, color: overallColor }}>
|
<td style={{ fontWeight: 700, color: overallColor }}>
|
||||||
{overallWinRate != null ? `${(overallWinRate * 100).toFixed(1)}%` : 'N/A'}
|
{overallWinRate != null ? `${(overallWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ fontSize: 12 }}>{bullTotal} signals</div>
|
<div style={{ fontSize: 12 }}>{bullTotal} 个信号</div>
|
||||||
<div style={{ fontSize: 10, color: '#666666' }}>{bullWins} wins</div>
|
<div style={{ fontSize: 10, color: '#666666' }}>{bullWins} 次命中</div>
|
||||||
{bullUnknown > 0 && (
|
{bullUnknown > 0 && (
|
||||||
<div style={{ fontSize: 10, color: '#999999' }}>{bullUnknown} unknown</div>
|
<div style={{ fontSize: 10, color: '#999999' }}>{bullUnknown} 条未判定</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ color: bullWinRate != null ? (bullWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
|
<td style={{ color: bullWinRate != null ? (bullWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
|
||||||
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : 'N/A'}
|
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ fontSize: 12 }}>{bearTotal} signals</div>
|
<div style={{ fontSize: 12 }}>{bearTotal} 个信号</div>
|
||||||
<div style={{ fontSize: 10, color: '#666666' }}>{bearWins} wins</div>
|
<div style={{ fontSize: 10, color: '#666666' }}>{bearWins} 次命中</div>
|
||||||
{bearUnknown > 0 && (
|
{bearUnknown > 0 && (
|
||||||
<div style={{ fontSize: 10, color: '#999999' }}>{bearUnknown} unknown</div>
|
<div style={{ fontSize: 10, color: '#999999' }}>{bearUnknown} 条未判定</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ color: bearWinRate != null ? (bearWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
|
<td style={{ color: bearWinRate != null ? (bearWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
|
||||||
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : 'N/A'}
|
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontWeight: 700 }}>{totalSignals}</td>
|
<td style={{ fontWeight: 700 }}>{totalSignals}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,7 +102,7 @@ export default function PerformanceView({ leaderboard }) {
|
|||||||
{rankedAgents.length > 0 && rankedAgents.some(agent => agent.signals && agent.signals.length > 0) && (
|
{rankedAgents.length > 0 && rankedAgents.some(agent => agent.signals && agent.signals.length > 0) && (
|
||||||
<div className="section" style={{ marginTop: 32 }}>
|
<div className="section" style={{ marginTop: 32 }}>
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2 className="section-title">Signal History</h2>
|
<h2 className="section-title">信号历史</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: 20 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: 20 }}>
|
||||||
@@ -150,7 +150,7 @@ export default function PerformanceView({ leaderboard }) {
|
|||||||
const hasRealReturn = typeof realReturnValue === 'number' && Number.isFinite(realReturnValue);
|
const hasRealReturn = typeof realReturnValue === 'number' && Number.isFinite(realReturnValue);
|
||||||
const realReturnDisplay = hasRealReturn
|
const realReturnDisplay = hasRealReturn
|
||||||
? `${realReturnValue >= 0 ? '+' : ''}${(realReturnValue * 100).toFixed(2)}%`
|
? `${realReturnValue >= 0 ? '+' : ''}${(realReturnValue * 100).toFixed(2)}%`
|
||||||
: 'Unknown';
|
: '未判定';
|
||||||
const realReturnColor = hasRealReturn
|
const realReturnColor = hasRealReturn
|
||||||
? (realReturnValue >= 0 ? '#00C853' : '#FF1744')
|
? (realReturnValue >= 0 ? '#00C853' : '#FF1744')
|
||||||
: '#999999';
|
: '#999999';
|
||||||
@@ -189,7 +189,7 @@ export default function PerformanceView({ leaderboard }) {
|
|||||||
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#999999',
|
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#999999',
|
||||||
fontSize: 12
|
fontSize: 12
|
||||||
}}>
|
}}>
|
||||||
{isBull ? 'Bull' : isBear ? 'Bear' : 'Neutral'}
|
{isBull ? '看涨' : isBear ? '看跌' : '中性'}
|
||||||
</span>
|
</span>
|
||||||
{!isNeutral && (
|
{!isNeutral && (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -222,7 +222,7 @@ export default function PerformanceView({ leaderboard }) {
|
|||||||
color: '#666666',
|
color: '#666666',
|
||||||
textAlign: 'center'
|
textAlign: 'center'
|
||||||
}}>
|
}}>
|
||||||
Total: {sortedSignals.length} signals
|
共 {sortedSignals.length} 条信号
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -233,4 +233,3 @@ export default function PerformanceView({ leaderboard }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { LLM_MODEL_LOGOS } from '../config/constants';
|
import { LLM_MODEL_LOGOS } from '../config/constants';
|
||||||
|
|
||||||
export default function RulesView() {
|
export default function RulesView() {
|
||||||
const [language, setLanguage] = useState('en'); // 'en' or 'zh'
|
const [language] = useState('zh');
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const contentRef = useRef(null);
|
const contentRef = useRef(null);
|
||||||
@@ -197,19 +197,19 @@ export default function RulesView() {
|
|||||||
section2Title: "Agent 决策机制",
|
section2Title: "Agent 决策机制",
|
||||||
|
|
||||||
tradingProcess: "交易流程",
|
tradingProcess: "交易流程",
|
||||||
tradingDesc: "Agents 进行日频交易并持续跟踪组合净值。每天最终交易决策前,agents 经历三个关键阶段:",
|
tradingDesc: "智能体以日频进行交易并持续跟踪组合净值。每天最终交易决策前,会经历三个关键阶段:",
|
||||||
|
|
||||||
analysisPhase: "• 分析阶段",
|
analysisPhase: "• 分析阶段",
|
||||||
analysisDesc: "所有 agents 根据各自的工具和信息独立分析并形成判断。",
|
analysisDesc: "所有智能体根据各自的工具和信息独立分析并形成判断。",
|
||||||
|
|
||||||
communicationPhase: "• 沟通阶段",
|
communicationPhase: "• 沟通阶段",
|
||||||
commIntro: "提供了多种沟通渠道:1v1 私聊 / 1vN 通知 / NvN 会议",
|
commIntro: "提供了多种沟通渠道:1v1 私聊 / 1vN 通知 / NvN 会议",
|
||||||
|
|
||||||
decisionPhase: "• 决策阶段",
|
decisionPhase: "• 决策阶段",
|
||||||
decisionDesc: "由 portfolio manager 汇总所有信息,并给出最终的团队交易。analysts 给出的原始交易信号仅做个人维度的排名。",
|
decisionDesc: "由投资经理汇总所有信息,并给出最终的团队交易决策。分析师给出的原始交易信号仅用于个人维度排名。",
|
||||||
|
|
||||||
reflectionTitle: "学习与进化",
|
reflectionTitle: "学习与进化",
|
||||||
reflectionDesc: "Agents 根据当日实际收益反思决策、总结经验,并存入 ",
|
reflectionDesc: "智能体根据当日实际收益反思决策、总结经验,并存入 ",
|
||||||
remeLink: "ReMe",
|
remeLink: "ReMe",
|
||||||
reflectionDesc2: " 记忆框架以持续改进。",
|
reflectionDesc2: " 记忆框架以持续改进。",
|
||||||
|
|
||||||
@@ -219,14 +219,14 @@ export default function RulesView() {
|
|||||||
chartDesc: "追踪组合收益曲线 vs. 基准策略(等权、市值加权、动量)。用于评估整体策略有效性。",
|
chartDesc: "追踪组合收益曲线 vs. 基准策略(等权、市值加权、动量)。用于评估整体策略有效性。",
|
||||||
|
|
||||||
rankingTitle: "• 分析师排名",
|
rankingTitle: "• 分析师排名",
|
||||||
rankingDesc: "在 Trading Room 点击头像查看分析师表现(胜率、牛/熊市胜率)。用于了解哪些分析师提供最有价值的洞察。",
|
rankingDesc: "在交易室点击头像查看分析师表现(胜率、牛/熊市胜率),用来了解哪些分析师提供了最有价值的洞察。",
|
||||||
|
|
||||||
statsTitle: "• 统计数据",
|
statsTitle: "• 统计数据",
|
||||||
statsDesc: "详细的持仓和交易历史。用于深入分析仓位管理和执行质量。",
|
statsDesc: "详细的持仓和交易历史。用于深入分析仓位管理和执行质量。",
|
||||||
|
|
||||||
callToAction: "在 ",
|
callToAction: "可在 ",
|
||||||
repoLink: "GitHub",
|
repoLink: "GitHub",
|
||||||
callToAction2: " 上 fork 并自定义!"
|
callToAction2: " 上 Fork 并自行定制。"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,136 +234,14 @@ export default function RulesView() {
|
|||||||
<div ref={containerRef} style={containerStyle}>
|
<div ref={containerRef} style={containerStyle}>
|
||||||
<div ref={contentRef} style={contentWrapperStyle}>
|
<div ref={contentRef} style={contentWrapperStyle}>
|
||||||
<div style={innerContentStyle}>
|
<div style={innerContentStyle}>
|
||||||
{/* Language Switch */}
|
|
||||||
<div style={languageSwitchStyle}>
|
<div style={languageSwitchStyle}>
|
||||||
<span
|
<span
|
||||||
style={getLangStyle(language === 'zh')}
|
style={getLangStyle(true)}
|
||||||
onClick={() => setLanguage('zh')}
|
|
||||||
>
|
>
|
||||||
中文
|
中文
|
||||||
</span>
|
</span>
|
||||||
<span style={{ padding: '0 4px', color: '#999' }}>|</span>
|
|
||||||
<span
|
|
||||||
style={getLangStyle(language === 'en')}
|
|
||||||
onClick={() => setLanguage('en')}
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{language === 'en' ? (
|
|
||||||
// English Content
|
|
||||||
<>
|
|
||||||
{/* Section 1: Agent Setup */}
|
|
||||||
<div style={sectionTitleStyle}>{content.en.section1Title}</div>
|
|
||||||
|
|
||||||
{/* Roles */}
|
|
||||||
<div style={{ marginBottom: '8px', fontSize: '12px' }}>
|
|
||||||
<div style={{ marginBottom: '3px' }}>
|
|
||||||
<span style={{ fontWeight: 600 }}>{content.en.pmRole}:</span> {content.en.pmDesc}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '3px' }}>
|
|
||||||
<span style={{ fontWeight: 600 }}>{content.en.rmRole}:</span> {content.en.rmDesc}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '3px' }}>
|
|
||||||
<span style={{ fontWeight: 600 }}>{content.en.analystsRole}:</span> {content.en.analystsDesc}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Analysts with AI Models */}
|
|
||||||
<div style={{ marginLeft: '10px', marginBottom: '8px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px 14px', fontSize: '11px' }}>
|
|
||||||
{content.en.analysts.map(analyst => {
|
|
||||||
const logo = llmLogos.find(l => l.name === analyst.modelKey);
|
|
||||||
return (
|
|
||||||
<div key={analyst.name} style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
}}>
|
|
||||||
{logo && (
|
|
||||||
<img
|
|
||||||
src={logo.url}
|
|
||||||
alt={logo.label}
|
|
||||||
style={{
|
|
||||||
height: '16px',
|
|
||||||
width: 'auto',
|
|
||||||
objectFit: 'contain'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span style={{ fontWeight: 600 }}>{analyst.name}</span>
|
|
||||||
<span style={{ color: '#666' }}>- {analyst.model}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '10px', fontSize: '11px', fontStyle: 'italic', opacity: 0.8 }}>
|
|
||||||
{content.en.callToAction}
|
|
||||||
<a
|
|
||||||
href="https://github.com/agentscope-ai/agentscope-samples"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={linkStyle}
|
|
||||||
>
|
|
||||||
{content.en.repoLink}
|
|
||||||
</a>
|
|
||||||
{content.en.callToAction2}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 2: Agent Decision Mechanism */}
|
|
||||||
<div style={sectionTitleStyle}>{content.en.section2Title}</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '6px' }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{content.en.tradingProcess}</div>
|
|
||||||
<div style={{ marginBottom: '6px', fontSize: '12px' }}>{content.en.tradingDesc}</div>
|
|
||||||
|
|
||||||
<div style={subsectionStyle}>
|
|
||||||
<div style={{ marginBottom: '4px', fontSize: '12px' }}>
|
|
||||||
<span style={highlight}>{content.en.analysisPhase.replace('• ', '')}:</span> {content.en.analysisDesc}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '4px', fontSize: '12px' }}>
|
|
||||||
<span style={highlight}>{content.en.communicationPhase.replace('• ', '')}:</span> {content.en.commIntro}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: '12px' }}>
|
|
||||||
<span style={highlight}>{content.en.decisionPhase.replace('• ', '')}:</span> {content.en.decisionDesc}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '10px' }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{content.en.reflectionTitle}</div>
|
|
||||||
<div style={{ fontSize: '12px' }}>
|
|
||||||
{content.en.reflectionDesc}
|
|
||||||
<a
|
|
||||||
href="https://github.com/agentscope-ai/ReMe"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={linkStyle}
|
|
||||||
>
|
|
||||||
{content.en.remeLink}
|
|
||||||
</a>
|
|
||||||
{content.en.reflectionDesc2}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 3: Performance Evaluation */}
|
|
||||||
<div style={sectionTitleStyle}>{content.en.section3Title}</div>
|
|
||||||
<div style={subsectionStyle}>
|
|
||||||
<div style={{ marginBottom: '3px', fontSize: '12px' }}>
|
|
||||||
<span style={{ fontWeight: 600 }}>{content.en.chartTitle.replace('• ', '')}:</span> {content.en.chartDesc}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '3px', fontSize: '12px' }}>
|
|
||||||
<span style={{ fontWeight: 600 }}>{content.en.rankingTitle.replace('• ', '')}:</span> {content.en.rankingDesc}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '12px' }}>
|
|
||||||
<span style={{ fontWeight: 600 }}>{content.en.statsTitle.replace('• ', '')}:</span> {content.en.statsDesc}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Chinese Content
|
// Chinese Content
|
||||||
<>
|
<>
|
||||||
{/* 第一部分:Agent 设定 */}
|
{/* 第一部分:Agent 设定 */}
|
||||||
@@ -475,7 +353,6 @@ export default function RulesView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
|
|||||||
}}>
|
}}>
|
||||||
{pmWinRateData?.winRate != null
|
{pmWinRateData?.winRate != null
|
||||||
? `${(pmWinRateData.winRate * 100).toFixed(1)}%`
|
? `${(pmWinRateData.winRate * 100).toFixed(1)}%`
|
||||||
: 'N/A'}
|
: '暂无'}
|
||||||
</div>
|
</div>
|
||||||
{pmWinRateData && (
|
{pmWinRateData && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export const AXIS_TICKS = 5;
|
|||||||
// WebSocket configuration
|
// WebSocket configuration
|
||||||
export const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8765";
|
export const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8765";
|
||||||
|
|
||||||
// Initial ticker symbols (MAG7 companies)
|
// Initial ticker symbols for the production watchlist
|
||||||
export const INITIAL_TICKERS = [
|
export const INITIAL_TICKERS = [
|
||||||
{ symbol: "AAPL", price: null, change: null },
|
{ symbol: "AAPL", price: null, change: null },
|
||||||
{ symbol: "MSFT", price: null, change: null },
|
{ symbol: "MSFT", price: null, change: null },
|
||||||
@@ -140,6 +140,10 @@ export const INITIAL_TICKERS = [
|
|||||||
{ symbol: "AMZN", price: null, change: null },
|
{ symbol: "AMZN", price: null, change: null },
|
||||||
{ symbol: "NVDA", price: null, change: null },
|
{ symbol: "NVDA", price: null, change: null },
|
||||||
{ symbol: "META", price: null, change: null },
|
{ symbol: "META", price: null, change: null },
|
||||||
{ symbol: "TSLA", price: null, change: null }
|
{ symbol: "TSLA", price: null, change: null },
|
||||||
|
{ symbol: "AMD", price: null, change: null },
|
||||||
|
{ symbol: "NFLX", price: null, change: null },
|
||||||
|
{ symbol: "AVGO", price: null, change: null },
|
||||||
|
{ symbol: "PLTR", price: null, change: null },
|
||||||
|
{ symbol: "COIN", price: null, change: null }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class ReadOnlyClient {
|
|||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.reconnectDelay = this.baseReconnectDelay;
|
this.reconnectDelay = this.baseReconnectDelay;
|
||||||
this.lastPongTime = Date.now();
|
this.lastPongTime = Date.now();
|
||||||
this._safeEmit({ type: "system", content: "Connected to live server" });
|
this._safeEmit({ type: "system", content: "已连接实时数据服务" });
|
||||||
console.log("WebSocket connected");
|
console.log("WebSocket connected");
|
||||||
this._startHeartbeat();
|
this._startHeartbeat();
|
||||||
};
|
};
|
||||||
@@ -79,7 +79,7 @@ export class ReadOnlyClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
this.ws.onclose = (event) => {
|
||||||
const code = event.code || "Unknown";
|
const code = event.code || "未知";
|
||||||
console.log(`[WebSocket] Connection closed: Code=${code}, WasClean=${event.wasClean}`);
|
console.log(`[WebSocket] Connection closed: Code=${code}, WasClean=${event.wasClean}`);
|
||||||
|
|
||||||
this._stopHeartbeat();
|
this._stopHeartbeat();
|
||||||
@@ -96,7 +96,7 @@ export class ReadOnlyClient {
|
|||||||
|
|
||||||
this._safeEmit({
|
this._safeEmit({
|
||||||
type: "system",
|
type: "system",
|
||||||
content: "Try to connect to data server..."
|
content: "正在尝试连接数据服务..."
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
@@ -189,4 +189,3 @@ export class ReadOnlyClient {
|
|||||||
this.ws = null;
|
this.ws = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export function getModelIcon(modelName, modelProvider) {
|
|||||||
logoPath: null,
|
logoPath: null,
|
||||||
color: "#666666",
|
color: "#666666",
|
||||||
bgColor: "#f5f5f5",
|
bgColor: "#f5f5f5",
|
||||||
label: "Default",
|
label: "默认",
|
||||||
provider: "Default"
|
provider: "默认"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ export function getModelIcon(modelName, modelProvider) {
|
|||||||
color: "#666666",
|
color: "#666666",
|
||||||
bgColor: "#f5f5f5",
|
bgColor: "#f5f5f5",
|
||||||
label: modelName.substring(0, 15),
|
label: modelName.substring(0, 15),
|
||||||
provider: provider || "Unknown"
|
provider: provider || "未知"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ export function getModelIcon(modelName, modelProvider) {
|
|||||||
*/
|
*/
|
||||||
export function getShortModelName(modelName) {
|
export function getShortModelName(modelName) {
|
||||||
if (!modelName) {
|
if (!modelName) {
|
||||||
return "N/A";
|
return "暂无";
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = modelName.toLowerCase();
|
const name = modelName.toLowerCase();
|
||||||
@@ -393,4 +393,3 @@ export function getShortModelName(modelName) {
|
|||||||
// Return formatted original name
|
// Return formatted original name
|
||||||
return capitalizeWords(modelName);
|
return capitalizeWords(modelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
scripts/run_prod.sh
Executable file
15
scripts/run_prod.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd /root/code/evotraders
|
||||||
|
|
||||||
|
export PYTHONPATH=/root/code/evotraders/.pydeps:.
|
||||||
|
export TICKERS="${TICKERS:-AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN}"
|
||||||
|
|
||||||
|
exec python3 -m backend.main \
|
||||||
|
--mode live \
|
||||||
|
--config-name production \
|
||||||
|
--host 127.0.0.1 \
|
||||||
|
--port 8765 \
|
||||||
|
--trigger-time now \
|
||||||
|
--poll-interval 15
|
||||||
Reference in New Issue
Block a user