Spaces:
Runtime error
Runtime error
File size: 7,544 Bytes
3d1576e 16520a2 79755bf 16520a2 79cd13b ae8671d 16520a2 79755bf 16520a2 ae8671d 3d1576e ae8671d 16520a2 79755bf 16520a2 79755bf 16520a2 79755bf 16520a2 79755bf 16520a2 79755bf 16520a2 79755bf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | """
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]) |