feat: Yahoo Finance SSH proxy + Xiaomi MiMo LLM support
CI / tests (py3.10) (push) Has been cancelled
CI / tests (py3.11) (push) Has been cancelled
CI / tests (py3.12) (push) Has been cancelled
CI / tests (py3.13) (push) Has been cancelled
CI / clean-install smoke (push) Has been cancelled
CI / ruff (strict, full repo) (push) Has been cancelled

- Add SSH proxy via foreign server for Yahoo Finance bypass
- Add fallback in load_ohlcv and get_YFin_data_online
- Add LLM JSON decode retry (3 attempts) in NormalizedChatOpenAI
- Add yahoo_proxy.py utility module
- Add test_smoke.py and patches/
This commit is contained in:
小钰 committed 2026-07-07 15:30:20 +08:00
1 parent 01477f9afb
commit 51d057e2c9
7 files changed
+824 -26

No files matched your search

+202 -24
View File
@@ -1,6 +1,8 @@
import logging
import os
import time
import json
import subprocess
from typing import Annotated
import pandas as pd
@@ -19,6 +21,82 @@ logger = logging.getLogger(__name__)
# enough to catch the year-old frames yfinance occasionally returns (#1021).
MAX_OHLCV_STALE_DAYS = 10
_ssh_config_cache = None
def _get_ssh_config():
"""Load SSH credentials for foreign server from vault (cached)."""
global _ssh_config_cache
if _ssh_config_cache is not None:
return _ssh_config_cache
result = subprocess.run(
["bash", "-c",
'source <(bash /root/.openclaw/workspace/scripts/vault.sh decrypt '
'/root/.openclaw/workspace/secrets/credentials.env.enc) '
'&& echo "$FOREIGN_HOST $FOREIGN_PORT $FOREIGN_PASS"'],
capture_output=True, text=True, timeout=10
)
parts = result.stdout.strip().split()
if len(parts) != 3:
raise RuntimeError(f"Failed to load SSH config: {result.stderr}")
_ssh_config_cache = (parts[0], int(parts[1]), parts[2])
return _ssh_config_cache
def _fetch_via_ssh(url: str, timeout: int = 30) -> str:
"""Fetch a URL through the foreign server via SSH tunnel."""
host, port, password = _get_ssh_config()
cmd = [
"sshpass", "-p", password,
"ssh", "-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=15",
"-p", str(port), f"root@{host}",
f'curl -s "{url}" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"'
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
raise RuntimeError(f"SSH fetch failed: {result.stderr}")
return result.stdout
def _yf_download_via_proxy(symbol: str, start: str, end: str) -> pd.DataFrame:
"""Download OHLCV data via SSH proxy when local Yahoo Finance is rate-limited."""
import datetime
# Convert dates to timestamps
start_ts = int(pd.Timestamp(start).timestamp())
end_ts = int(pd.Timestamp(end).timestamp())
url = (f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}"
f"?period1={start_ts}&period2={end_ts}&interval=1d")
data_str = _fetch_via_ssh(url)
parsed = json.loads(data_str)
result = parsed.get("chart", {}).get("result", [])
if not result:
return pd.DataFrame()
ts = result[0].get("timestamp", [])
indicators = result[0].get("indicators", {}).get("quote", [{}])[0]
if not ts:
return pd.DataFrame()
df = pd.DataFrame({
"Date": pd.to_datetime(ts, unit="s"),
"Open": indicators.get("open", []),
"High": indicators.get("high", []),
"Low": indicators.get("low", []),
"Close": indicators.get("close", []),
"Volume": indicators.get("volume", []),
})
# Convert None values to NaN
for col in ["Open", "High", "Low", "Close", "Volume"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.dropna(subset=["Close"])
return df
def yf_retry(func, max_retries=3, base_delay=2.0):
"""Execute a yfinance call with exponential backoff on rate limits.
@@ -36,6 +114,7 @@ def yf_retry(func, max_retries=3, base_delay=2.0):
logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})")
time.sleep(delay)
else:
logger.warning("Yahoo Finance local exhausted, will let caller handle fallback")
raise
@@ -122,6 +201,82 @@ def _assert_ohlcv_not_stale(
)
def _load_ohlcv_alpha_vantage(symbol: str, curr_date: str) -> pd.DataFrame:
"""Fetch OHLCV data from Alpha Vantage, filtered to prevent look-ahead bias."""
from .alpha_vantage_common import _make_api_request
from io import StringIO
canonical = normalize_symbol(symbol)
curr_date_dt = pd.to_datetime(curr_date)
today_date = pd.Timestamp.today()
start_date = today_date - pd.DateOffset(years=5)
start_str = start_date.strftime("%Y-%m-%d")
# Determine outputsize based on how far back we need
days_from_today = (today_date - start_date).days
outputsize = "compact" if days_from_today < 100 else "full"
params = {
"symbol": canonical,
"outputsize": outputsize,
"datatype": "csv",
}
try:
csv_text = _make_api_request("TIME_SERIES_DAILY", params)
except Exception as e:
raise NoMarketDataError(symbol, canonical, f"Alpha Vantage error: {e}") from e
if not csv_text or not csv_text.strip():
raise NoMarketDataError(symbol, canonical, "Alpha Vantage returned empty data")
try:
df = pd.read_csv(StringIO(csv_text))
except Exception as e:
raise NoMarketDataError(symbol, canonical, f"Failed to parse Alpha Vantage CSV: {e}") from e
if df.empty:
raise NoMarketDataError(symbol, canonical, "Alpha Vantage returned no rows")
# Alpha Vantage CSV has 'timestamp' column; normalize to 'Date'
date_col = df.columns[0] # usually 'timestamp'
df = df.rename(columns={date_col: "Date"})
# Keep only standard OHLCV columns
keep_cols = ["Date"]
col_map = {}
for av_col, std_col in [
("open", "Open"), ("high", "High"), ("low", "Low"),
("close", "Close"), ("adjusted_close", "Close"), ("volume", "Volume"),
]:
for c in df.columns:
if c.lower().replace(" ", "_") == av_col:
if std_col not in keep_cols:
keep_cols.append(std_col)
col_map[c] = std_col
break
df = df.rename(columns=col_map)
# Prefer adjusted_close over close for "Close"
if "adjusted_close" in [c.lower().replace(" ", "_") for c in df.columns]:
for c in df.columns:
if c.lower().replace(" ", "_") == "adjusted_close":
df["Close"] = pd.to_numeric(df[c], errors="coerce")
break
available = [c for c in ["Date", "Open", "High", "Low", "Close", "Volume"] if c in df.columns]
df = df[available]
# Filter to curr_date
df["Date"] = pd.to_datetime(df["Date"], errors="coerce")
df = df.dropna(subset=["Date"])
df = df[df["Date"] <= curr_date_dt].sort_values("Date")
if df.empty:
raise NoMarketDataError(symbol, canonical, "No data on or before " + curr_date)
return df
def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
@@ -138,24 +293,24 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
config = get_config()
curr_date_dt = pd.to_datetime(curr_date)
# Check which vendor to use for OHLCV
vendor_config = config.get("data_vendors", {}).get("core_stock_apis", "yfinance")
use_alpha_vantage = "alpha_vantage" in vendor_config
# Cache uses a fixed window (5y to today) so one file per symbol.
today_date = pd.Timestamp.today()
start_date = today_date - pd.DateOffset(years=5)
start_str = start_date.strftime("%Y-%m-%d")
# yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included
# when curr_date is the current day (#986). Look-ahead is still prevented by
# the curr_date filter below.
end_str = (today_date + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
cache_prefix = "AV" if use_alpha_vantage else "YFin"
os.makedirs(config["data_cache_dir"], exist_ok=True)
data_file = os.path.join(
config["data_cache_dir"],
f"{safe_symbol}-YFin-data-{start_str}-{end_str}.csv",
f"{safe_symbol}-{cache_prefix}-data-{start_str}-{end_str}.csv",
)
# A cached file may be empty if a prior fetch failed (unknown symbol,
# transient rate limit). Treat an empty/columnless cache as a miss and
# re-fetch rather than serving the poisoned file forever.
# A cached file may be empty if a prior fetch failed. Treat empty cache as miss.
data = None
if os.path.exists(data_file):
cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
@@ -163,30 +318,53 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
data = cached
if data is None:
downloaded = yf_retry(lambda: yf.download(
canonical,
start=start_str,
end=end_str,
multi_level_index=False,
progress=False,
auto_adjust=True,
))
downloaded = _ensure_date_column(downloaded.reset_index())
# Only cache real data — never persist an empty frame.
if downloaded.empty or "Close" not in downloaded.columns:
raise NoMarketDataError(
symbol, canonical, "Yahoo Finance returned no rows"
)
if use_alpha_vantage:
downloaded = _load_ohlcv_alpha_vantage(symbol, curr_date)
else:
downloaded = None
try:
raw = yf_retry(lambda: yf.download(
canonical,
start=start_str,
end=end_str,
multi_level_index=False,
progress=False,
auto_adjust=True,
))
downloaded = _ensure_date_column(raw.reset_index())
except Exception as yf_err:
pass # Will try proxy below
# If local yfinance failed or returned empty, try SSH proxy
if downloaded is None or downloaded.empty or "Close" not in downloaded.columns:
if downloaded is None or downloaded.empty:
logger.warning(f"Yahoo Finance local failed, trying SSH proxy...")
try:
downloaded = _yf_download_via_proxy(canonical, start_str, end_str)
except Exception as proxy_err:
raise NoMarketDataError(
symbol, canonical, f"Yahoo Finance proxy also failed: {proxy_err}"
) from proxy_err
if downloaded.empty:
raise NoMarketDataError(
symbol, canonical, f"Yahoo Finance proxy also returned no data"
)
if downloaded.empty or "Close" not in downloaded.columns:
raise NoMarketDataError(
symbol, canonical, "Yahoo Finance returned no rows"
)
downloaded = _clean_dataframe(downloaded)
downloaded.to_csv(data_file, index=False, encoding="utf-8")
data = downloaded
data = _clean_dataframe(data)
if not use_alpha_vantage:
data = _clean_dataframe(data)
# Filter to curr_date to prevent look-ahead bias in backtesting
data = data[data["Date"] <= curr_date_dt]
# Reject a stale frame (latest row far older than curr_date) rather than
# feeding year-old prices into indicators (#1021).
# Reject a stale frame
_assert_ohlcv_not_stale(data, curr_date, symbol, canonical)
return data