Chan-Compass / data_us.py
ranranrunforit's picture
Update data_us.py
79755bf verified
Raw
History Blame Contribute Delete
7.54 kB
"""
data_us.py — US market data layer (yfinance), replacing baostock/pytdx.
Levels & history limits (Yahoo Finance API constraints):
daily : 10 years (weekly / monthly are resampled from daily
by chan_multilevel.resample_weekly/_monthly)
60m : last 730 days (fetched as "1h" interval with explicit start/end dates)
30m/15m : last 60 days
5m : last 60 days
1m : last 7 days only → too short for Chan decomposition, NOT used.
MultiLevelChan handles a missing 1m level gracefully (skips it).
Output schema (identical to the original A-share loaders):
date, open, close, high, low, volume, amount
`amount` (turnover) is approximated as close × volume (Yahoo has no turnover field).
All downloads are cached to parquet under ./_cache_us/<TICKER>/<level>.parquet
and refreshed when stale (daily: >12h old, intraday: >2h old) or on force=True.
"""
from __future__ import annotations
import os
import threading
import time
import traceback
import pandas as pd
import paths
# yfinance uses a shared SQLite cache (peewee) for timezone lookups.
# When multiple threads call yf.Ticker().history() simultaneously the DB
# gets locked and raises peewee.OperationalError, stalling prefetch and
# freezing the "Run analysis" button. Serialise the yfinance connect/lookup
# phase with a process-wide lock — Yahoo's own rate-limit is the real
# bottleneck anyway, so the extra serialisation costs almost nothing.
_YF_LOCK = threading.Lock()
CACHE_DIR = os.environ.get("CHAN_CACHE_DIR", paths.CACHE_DIR)
LEVELS = {
# level: (yfinance interval, period_or_days)
# For "60m" Yahoo requires explicit start/end dates (not a period string)
# when fetching more than ~60 days back; we pass days as an int sentinel.
"d": ("1d", "10y"),
"60m": ("1h", "730d"), # use explicit start/end — "period='730d'" is rejected by Yahoo for 1h
"30m": ("30m", "60d"),
"15m": ("15m", "60d"),
"5m": ("5m", "60d"),
"1m": ("1m", "7d"), # only 7 days available; short but usable for the
# finest nested-interval confirmation when present
}
_STALE_SECONDS = {"d": 12 * 3600, "60m": 2 * 3600, "30m": 2 * 3600,
"15m": 2 * 3600, "5m": 2 * 3600, "1m": 1800}
def _cache_path(ticker: str, level: str) -> str:
d = os.path.join(CACHE_DIR, ticker.upper().replace("/", "_"))
os.makedirs(d, exist_ok=True)
return os.path.join(d, f"{level}.parquet")
def _normalize(df: pd.DataFrame) -> pd.DataFrame:
"""yfinance frame → engine schema (date/open/close/high/low/volume/amount)."""
if df is None or len(df) == 0:
return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
d = df.copy()
if isinstance(d.columns, pd.MultiIndex): # yf>=0.2 returns MultiIndex sometimes
d.columns = [c[0] if isinstance(c, tuple) else c for c in d.columns]
d = d.reset_index()
# index column may be 'Date' or 'Datetime'
for cand in ("Datetime", "Date", "index"):
if cand in d.columns:
d = d.rename(columns={cand: "date"})
break
d.columns = [str(c).lower() for c in d.columns]
keep = {"date", "open", "high", "low", "close", "volume"}
d = d[[c for c in d.columns if c in keep]]
d["date"] = pd.to_datetime(d["date"])
# strip timezone so comparisons with naive Timestamps in the engine work
try:
d["date"] = d["date"].dt.tz_localize(None)
except (TypeError, AttributeError):
pass
d = d.dropna(subset=["open", "high", "low", "close"])
d = d.sort_values("date").reset_index(drop=True)
d["amount"] = d["close"] * d.get("volume", 0)
return d[["date", "open", "close", "high", "low", "volume", "amount"]]
def load_level(ticker: str, level: str, force: bool = False) -> pd.DataFrame:
"""Load one level for a ticker, using parquet cache when fresh."""
assert level in LEVELS, f"unknown level {level}"
path = _cache_path(ticker, level)
if not force and os.path.exists(path):
age = time.time() - os.path.getmtime(path)
if age < _STALE_SECONDS[level]:
try:
return pd.read_parquet(path)
except Exception:
pass
try:
import yfinance as yf
from datetime import datetime, timedelta
interval, period = LEVELS[level]
# Acquire lock before any yfinance call — the shared peewee/SQLite
# timezone cache raises "database is locked" under concurrent access.
with _YF_LOCK:
if isinstance(period, int):
# Yahoo rejects period strings for hourly data older than ~60 days.
# Use explicit start/end timestamps instead.
end_dt = datetime.utcnow()
start_dt = end_dt - timedelta(days=period)
raw = yf.Ticker(ticker).history(start=start_dt, end=end_dt,
interval=interval,
auto_adjust=True, actions=False)
else:
raw = yf.Ticker(ticker).history(period=period, interval=interval,
auto_adjust=True, actions=False)
df = _normalize(raw)
if len(df):
df.to_parquet(path, index=False)
return df
except Exception:
traceback.print_exc()
# network failed → fall back to stale cache if any
if os.path.exists(path):
try:
return pd.read_parquet(path)
except Exception:
pass
return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
# Full nested-interval set (区间套): the more sub-levels confirm, the more
# precise the buy/sell point. We fetch the deepest Yahoo allows. 1m has only
# 7 days of history — included when present, skipped gracefully otherwise.
# Downloads are parallel + cached, so the extra levels cost little wall-time.
FULL_LEVELS = ("d", "60m", "30m", "15m", "5m", "1m")
FAST_LEVELS = FULL_LEVELS # default everywhere; alias kept for older callers
def load_levels(ticker: str, levels=FAST_LEVELS, force: bool = False) -> dict:
return {lvl: load_level(ticker, lvl, force=force) for lvl in levels}
def load_all_levels(ticker: str, force: bool = False) -> dict:
"""Return {'d':…, '60m':…, '30m':…, '15m':…, '5m':…} (1m intentionally absent)."""
return {lvl: load_level(ticker, lvl, force=force) for lvl in LEVELS}
def prefetch(tickers, levels=FAST_LEVELS, force: bool = False, workers: int = 5,
budget_s: int = 45):
"""Download all (ticker, level) pairs in parallel with a hard time budget.
Yahoo rate-limits datacenter IPs; without a budget one throttled request
could hang the whole Run-analysis click. Whatever isn't fetched in time is
skipped — the engine analyzes from daily/cached data and the next run
picks up the rest."""
from concurrent.futures import ThreadPoolExecutor, wait
jobs = [(t, lvl) for t in tickers for lvl in levels]
ex = ThreadPoolExecutor(max_workers=workers)
futs = [ex.submit(load_level, t, lvl, force) for t, lvl in jobs]
done, not_done = wait(futs, timeout=budget_s)
ex.shutdown(wait=False, cancel_futures=True)
return len(done), len(not_done)
def last_daily_date(ticker: str):
df = load_level(ticker, "d")
return None if df.empty else pd.Timestamp(df["date"].iloc[-1])