import json import os import argparse from dataclasses import dataclass from datetime import date, datetime, timezone from io import StringIO from typing import Any, Dict, List, Optional, Tuple import urllib.error import urllib.parse import urllib.request import numpy as np import pandas as pd from ass1_core import load_json_first_document @dataclass(frozen=True) class FetchConfig: # Step 2: 起始日期 = Today - 3 Years start: str = (datetime.now() - pd.DateOffset(years=3)).strftime("%Y-%m-%d") end: str = datetime.now().strftime("%Y-%m-%d") zscore_threshold: float = 3.0 def check_symbol_validity(symbol: str) -> bool: """ Step 2: 实现 check_symbol_validity 函数 检查标的是否存在且有交易数据 """ import akshare as ak try: # 简单尝试获取最近一天的行情来验证 if symbol.upper() == "AU0": df = ak.futures_main_ak(symbol="AU0") else: # 尝试美股 df = ak.stock_us_daily(symbol=symbol.upper(), adjust="qfq") return not df.empty except: return False def _now_iso() -> str: return datetime.utcnow().isoformat() + "Z" def _to_yyyymmdd(date_str: str) -> str: return date_str.replace("-", "") _REQUESTS_HTTP_PATCHED = False _SYMBOL_ALIASES = {"TSMC": "TSM"} def _enable_http_for_requests(logs: List[Dict[str, Any]]): global _REQUESTS_HTTP_PATCHED if _REQUESTS_HTTP_PATCHED: return import requests original = requests.sessions.Session.request def patched(self, method, url, *args, **kwargs): if isinstance(url, str) and url.startswith("https://"): url = "http://" + url[len("https://") :] return original(self, method, url, *args, **kwargs) requests.sessions.Session.request = patched _REQUESTS_HTTP_PATCHED = True logs.append({"time": _now_iso(), "level": "INFO", "code": "REQUESTS_HTTP_PATCHED", "message": "force https->http"}) def _normalize_us_hist_symbol(symbol: str) -> str: s = symbol.strip() if "." in s: return s return f"105.{s}" def _standardize_date_close(df: pd.DataFrame) -> pd.DataFrame: if df is None or df.empty: return pd.DataFrame(columns=["date", "close"]) out = df.copy() out.columns = [str(c).strip().lower() for c in out.columns] date_col = None for c in ["date", "日期", "time", "datetime", "timestamp"]: if c.lower() in out.columns: date_col = c.lower() break close_col = None for c in ["close", "收盘价", "收盘", "close_price", "closing"]: if c.lower() in out.columns: close_col = c.lower() break if date_col is None or close_col is None: raise RuntimeError(f"无法识别 date/close 列: columns={list(out.columns)}") out = out[[date_col, close_col]].rename(columns={date_col: "date", close_col: "close"}) out["date"] = pd.to_datetime(out["date"], errors="coerce").dt.date.astype("string") out["close"] = pd.to_numeric(out["close"], errors="coerce") out = out.dropna(subset=["date", "close"]) out["date"] = out["date"].astype(str) out = out.sort_values("date").reset_index(drop=True) return out def _filter_date_range(df: pd.DataFrame, start: str, end: str) -> pd.DataFrame: if df is None or df.empty: return pd.DataFrame(columns=["date", "close"]) m = (df["date"] >= start) & (df["date"] <= end) out = df.loc[m].copy() out = out.sort_values("date").reset_index(drop=True) return out def _dedup_by_date(df: pd.DataFrame) -> Tuple[pd.DataFrame, int]: if df is None or df.empty: return pd.DataFrame(columns=["date", "close"]), 0 before = len(df) out = df.sort_values("date").drop_duplicates(subset=["date"], keep="last").reset_index(drop=True) return out, before - len(out) def _zscore_filter_on_returns(df: pd.DataFrame, threshold: float) -> Tuple[pd.DataFrame, List[Dict[str, Any]]]: if df is None or df.empty or len(df) < 5: return df, [] out = df.copy() out["ret"] = out["close"].pct_change() rets = out["ret"].to_numpy(dtype=float) mask = np.isfinite(rets) if mask.sum() < 5: out = out.drop(columns=["ret"]) return out, [] mu = float(np.nanmean(rets[mask])) sigma = float(np.nanstd(rets[mask])) if sigma == 0.0 or not np.isfinite(sigma): out = out.drop(columns=["ret"]) return out, [] z = (rets - mu) / sigma outliers: List[Dict[str, Any]] = [] keep = np.ones(len(out), dtype=bool) for i in range(len(out)): if not np.isfinite(z[i]): continue if abs(float(z[i])) > threshold: keep[i] = False outliers.append( {"date": str(out["date"].iloc[i]), "close": float(out["close"].iloc[i]), "ret": float(rets[i]), "z": float(z[i])} ) out = out.loc[keep].drop(columns=["ret"]).reset_index(drop=True) return out, outliers def fetch_us_daily_qfq(symbol: str, start: str, end: str, logs: List[Dict[str, Any]]) -> pd.DataFrame: _enable_http_for_requests(logs) try: import akshare as ak except Exception as e: logs.append({"time": _now_iso(), "level": "ERROR", "code": "AK_IMPORT_FAIL", "message": str(e), "context": {"symbol": symbol}}) raise def _fetch_yahoo_chart_daily(sym: str) -> pd.DataFrame: """ Fallback US daily close from Yahoo Finance chart API (urllib only — avoids ``requests`` https→http monkeypatch used for AkShare). """ import pandas as _pd s = sym.strip().upper() def _utc_ts(dstr: str) -> int: return int(datetime.strptime(dstr[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()) t0 = _utc_ts(start) t1 = _utc_ts(end) + 86400 * 5 qs = urllib.parse.urlencode({"period1": t0, "period2": t1, "interval": "1d"}) url = f"https://query1.finance.yahoo.com/v8/finance/chart/{urllib.parse.quote(s)}?{qs}" req = urllib.request.Request( url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"}, ) try: with urllib.request.urlopen(req, timeout=60) as resp: raw = resp.read().decode("utf-8") except urllib.error.HTTPError as e: logs.append( { "time": _now_iso(), "level": "ERROR", "code": "YAHOO_CHART_HTTP", "message": str(e), "context": {"symbol": s, "url": url}, } ) raise except Exception as e: logs.append( {"time": _now_iso(), "level": "ERROR", "code": "YAHOO_CHART_FETCH_FAIL", "message": str(e), "context": {"symbol": s, "url": url}} ) raise try: js = json.loads(raw) except json.JSONDecodeError as e: logs.append({"time": _now_iso(), "level": "ERROR", "code": "YAHOO_CHART_JSON", "message": str(e), "context": {"symbol": s}}) raise chart = js.get("chart") or {} err = chart.get("error") if err: logs.append({"time": _now_iso(), "level": "ERROR", "code": "YAHOO_CHART_ERR", "message": str(err), "context": {"symbol": s}}) raise RuntimeError(str(err)) results = chart.get("result") or [] if not results: logs.append({"time": _now_iso(), "level": "ERROR", "code": "YAHOO_CHART_EMPTY", "message": "no result", "context": {"symbol": s}}) raise RuntimeError("Yahoo chart: empty result") res = results[0] ts_list = res.get("timestamp") or [] quotes = ((res.get("indicators") or {}).get("quote") or [{}])[0] closes = quotes.get("close") or [] if not ts_list or not closes or len(ts_list) != len(closes): logs.append( { "time": _now_iso(), "level": "ERROR", "code": "YAHOO_CHART_SHAPE", "message": "timestamp/close mismatch", "context": {"symbol": s, "n_ts": len(ts_list), "n_c": len(closes)}, } ) raise RuntimeError("Yahoo chart: timestamp/close length mismatch") rows: List[Dict[str, Any]] = [] for t, c in zip(ts_list, closes): if t is None or c is None: continue d = datetime.fromtimestamp(int(t), tz=timezone.utc).date().isoformat() rows.append({"date": d, "close": float(c)}) out = _pd.DataFrame(rows) if out.empty: logs.append({"time": _now_iso(), "level": "ERROR", "code": "YAHOO_CHART_NOROWS", "message": "no valid bars", "context": {"symbol": s}}) raise RuntimeError("Yahoo chart: no valid rows after filtering") out = out.sort_values("date").reset_index(drop=True) logs.append({"time": _now_iso(), "level": "INFO", "code": "YAHOO_CHART_OK", "message": "yahoo chart v8", "context": {"symbol": s}}) return out def _fetch_stooq(sym: str) -> pd.DataFrame: """ Fallback US daily close from Stooq CSV. - URL format: https://stooq.com/q/d/l/?s=spy.us&i=d - Columns: Date, Open, High, Low, Close, Volume - Stooq may require ``STOOQ_API_KEY`` in env (plain CSV without key returns instructions, not data). """ import pandas as _pd stooq_code = f"{sym.strip().lower()}.us" apikey = os.environ.get("STOOQ_API_KEY", "").strip() url = f"https://stooq.com/q/d/l/?s={stooq_code}&i=d" if apikey: url = f"{url}&apikey={urllib.parse.quote(apikey)}" req = urllib.request.Request( url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, ) try: with urllib.request.urlopen(req, timeout=60) as resp: raw = resp.read().decode("utf-8", errors="replace") except Exception as e: logs.append({"time": _now_iso(), "level": "ERROR", "code": "STOOQ_FETCH_FAIL", "message": str(e), "context": {"symbol": sym, "url": url}}) raise if "Get your apikey" in raw or raw.lstrip().lower().startswith("get your apikey"): msg = "Stooq 已要求 apikey;未设置 STOOQ_API_KEY 时无法使用该源" logs.append({"time": _now_iso(), "level": "WARN", "code": "STOOQ_APIKEY_REQUIRED", "message": msg, "context": {"symbol": sym}}) raise RuntimeError(msg) header_idx = None lines = raw.splitlines() for i, line in enumerate(lines): low = line.strip().lower() if low.startswith("date,") or ("," in line and "date" in low and "close" in low): header_idx = i break if header_idx is None: logs.append( { "time": _now_iso(), "level": "ERROR", "code": "STOOQ_NOT_CSV", "message": "response is not a Stooq CSV", "context": {"symbol": sym, "preview": raw[:200]}, } ) raise RuntimeError("Stooq: response is not a recognizable CSV") body = "\n".join(lines[header_idx:]) try: df = _pd.read_csv(StringIO(body), engine="python", on_bad_lines="skip") except TypeError: df = _pd.read_csv(StringIO(body), engine="python", error_bad_lines=False, warn_bad_lines=False) if df is None or df.empty: logs.append({"time": _now_iso(), "level": "ERROR", "code": "STOOQ_EMPTY", "message": "empty dataframe", "context": {"symbol": sym, "url": url}}) return _pd.DataFrame(columns=["date", "close"]) cols = {c.lower(): c for c in df.columns} if "date" not in cols or "close" not in cols: raise RuntimeError(f"Stooq CSV 缺少 Date/Close 列: columns={list(df.columns)}") out = df[[cols["date"], cols["close"]]].rename(columns={cols["date"]: "date", cols["close"]: "close"}) out["date"] = _pd.to_datetime(out["date"], errors="coerce").dt.date.astype("string") out["close"] = _pd.to_numeric(out["close"], errors="coerce") out = out.dropna(subset=["date", "close"]) out["date"] = out["date"].astype(str) out = out.sort_values("date").reset_index(drop=True) logs.append({"time": _now_iso(), "level": "INFO", "code": "STOOQ_OK", "message": "stooq csv", "context": {"symbol": sym, "url": url}}) return out df = None try: df = ak.stock_us_daily(symbol=symbol, adjust="qfq") logs.append({"time": _now_iso(), "level": "INFO", "code": "AK_US_DAILY_OK", "message": "stock_us_daily", "context": {"symbol": symbol}}) except Exception as e: logs.append({"time": _now_iso(), "level": "WARN", "code": "AK_US_DAILY_FAIL", "message": str(e), "context": {"symbol": symbol}}) if df is None or getattr(df, "empty", True): try: df = ak.stock_us_hist( symbol=_normalize_us_hist_symbol(symbol), period="daily", start_date=_to_yyyymmdd(start), end_date=_to_yyyymmdd(end), adjust="qfq", ) if df is None or getattr(df, "empty", True): raise RuntimeError("stock_us_hist returned empty/None") logs.append({"time": _now_iso(), "level": "INFO", "code": "AK_US_HIST_OK", "message": "stock_us_hist", "context": {"symbol": symbol}}) except Exception as e: # AkShare sometimes returns None payload or breaks on US tickers/ETFs. logs.append({"time": _now_iso(), "level": "WARN", "code": "AK_US_HIST_FAIL", "message": str(e), "context": {"symbol": symbol}}) try: df = _fetch_yahoo_chart_daily(symbol) except Exception as e_y: logs.append( { "time": _now_iso(), "level": "WARN", "code": "YAHOO_CHART_FALLBACK_FAIL", "message": str(e_y), "context": {"symbol": symbol}, } ) df = _fetch_stooq(symbol) return _filter_date_range(df, start, end) return _standardize_date_close(df) def fetch_futures_au0_daily(symbol: str, start: str, end: str, logs: List[Dict[str, Any]]) -> pd.DataFrame: _enable_http_for_requests(logs) try: import akshare as ak except Exception as e: logs.append({"time": _now_iso(), "level": "ERROR", "code": "AK_IMPORT_FAIL", "message": str(e), "context": {"symbol": symbol}}) raise y1 = _to_yyyymmdd(start) y2 = _to_yyyymmdd(end) if not hasattr(ak, "futures_main_sina"): raise RuntimeError("当前 AkShare 版本缺少 futures_main_sina,无法拉取 AU0") last_err: Optional[Exception] = None for sym_try in [symbol, symbol.upper(), symbol.lower()]: try: df = ak.futures_main_sina(symbol=sym_try, start_date=y1, end_date=y2) logs.append( {"time": _now_iso(), "level": "INFO", "code": "AK_FUT_MAIN_SINA_OK", "message": "futures_main_sina", "context": {"symbol": sym_try}} ) return _standardize_date_close(df) except Exception as e: last_err = e logs.append( {"time": _now_iso(), "level": "WARN", "code": "AK_FUT_MAIN_SINA_FAIL", "message": str(e), "context": {"symbol": sym_try}} ) raise RuntimeError(str(last_err) if last_err is not None else "futures_main_sina failed") def _summary_stats(symbol: str, df_raw: pd.DataFrame, df_clean: pd.DataFrame, outliers: List[Dict[str, Any]], dup_removed: int) -> Dict[str, Any]: s: Dict[str, Any] = { "symbol": symbol, "n_raw": int(len(df_raw)) if df_raw is not None else 0, "n_clean": int(len(df_clean)) if df_clean is not None else 0, "duplicate_date_removed": int(dup_removed), "zscore_outlier_removed_count": int(len(outliers)), } if df_clean is None or df_clean.empty: return s closes = df_clean["close"].astype(float) s.update( { "date_min": str(df_clean["date"].iloc[0]), "date_max": str(df_clean["date"].iloc[-1]), "close_mean": float(closes.mean()), "close_min": float(closes.min()), "close_max": float(closes.max()), "first_close": float(closes.iloc[0]), "last_close": float(closes.iloc[-1]), } ) return s def _monthly_means(df: pd.DataFrame) -> List[Dict[str, Any]]: if df is None or df.empty: return [] tmp = df.copy() tmp["ym"] = tmp["date"].str.slice(0, 7) g = tmp.groupby("ym", as_index=False).agg(mean_close=("close", "mean"), n=("close", "count")) out = [] for _, r in g.iterrows(): out.append({"month": str(r["ym"]), "mean_close": float(r["mean_close"]), "n": int(r["n"])}) out = sorted(out, key=lambda x: x["month"]) return out def _default_symbols() -> Tuple[List[str], List[str]]: # Align with Phase0.md universe (tech, hedge, safe, benchmark) assets = ["SPY", "GLD", "TLT", "XLE", "USO", "AU0"] stocks = ["NVDA", "MSFT", "TSMC", "GOOGL", "AAPL"] return assets, stocks def _universe_symbols() -> List[str]: assets, stocks = _default_symbols() # Keep list stable for output ordering, but MUST be unique (duplicate columns break pandas selection) ordered = [*stocks, "XLE", "USO", "GLD", "TLT", "SPY", "AU0"] seen = set() out: List[str] = [] for s in ordered: if s in seen: continue seen.add(s) out.append(s) return out def _train_test_meta(cfg: FetchConfig) -> Dict[str, str]: from research.sentiment_calendar import DEFAULT_TEST_START train_start = "2024-01-01" train_end = "2026-01-31" test_start = DEFAULT_TEST_START test_end = min(cfg.end, date.today().isoformat()) return { "train_start": max(cfg.start, train_start), "train_end": min(cfg.end, train_end), "test_start": max(cfg.start, test_start), "test_end": test_end, } def _new_payload(cfg: FetchConfig) -> Dict[str, Any]: assets, stocks = _default_symbols() universe = _universe_symbols() split = _train_test_meta(cfg) return { "meta": { "source": "akshare", "generated_at": _now_iso(), "start": cfg.start, "end": cfg.end, "assets": assets, "stocks": stocks, "universe": universe, **split, "zscore_threshold": cfg.zscore_threshold, }, "assets": {}, "stocks": {}, "summary": {"assets": {}, "stocks": {}}, "outliers": {}, "monthly_means": {}, "logs": [], } def _load_payload(path: str, cfg: FetchConfig) -> Dict[str, Any]: if not os.path.exists(path): return _new_payload(cfg) data = load_json_first_document(path) if "meta" not in data: return _new_payload(cfg) assets, stocks = _default_symbols() universe = _universe_symbols() split = _train_test_meta(cfg) data["meta"]["generated_at"] = _now_iso() data["meta"]["start"] = cfg.start data["meta"]["end"] = cfg.end data["meta"]["zscore_threshold"] = cfg.zscore_threshold data["meta"]["assets"] = assets data["meta"]["stocks"] = stocks data["meta"]["universe"] = universe data["meta"].update(split) for k in ["assets", "stocks", "summary", "outliers", "monthly_means", "logs"]: if k not in data: data[k] = {} if k != "logs" else [] if "assets" not in data["summary"]: data["summary"]["assets"] = {} if "stocks" not in data["summary"]: data["summary"]["stocks"] = {} return data def _write_outputs(out_dir: str, payload: Dict[str, Any]): json_path = os.path.join(out_dir, "data.json") txt_path = os.path.join(out_dir, "read.txt") with open(json_path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) universe = payload.get("meta", {}).get("universe", None) or _universe_symbols() lines: List[str] = [] for sym in universe: lines.append(sym) for row in payload.get("monthly_means", {}).get(sym, []): lines.append(f" {row['month']}: mean_close={row['mean_close']:.6f}, n={row['n']}") lines.append("") read_txt = "\n".join(lines).rstrip() + "\n" with open(txt_path, "w", encoding="utf-8") as f: f.write(read_txt) def download_one(cfg: FetchConfig, symbol: str, kind: str, payload: Dict[str, Any]) -> Dict[str, Any]: logs: List[Dict[str, Any]] = [] sym = symbol.strip().upper() fetch_sym = _SYMBOL_ALIASES.get(sym, sym) if fetch_sym != sym: logs.append({"time": _now_iso(), "level": "INFO", "code": "SYMBOL_ALIAS", "message": "alias", "context": {"symbol": sym, "fetch_symbol": fetch_sym}}) if sym == "AU0": df0 = fetch_futures_au0_daily(sym, cfg.start, cfg.end, logs) else: df0 = fetch_us_daily_qfq(fetch_sym, cfg.start, cfg.end, logs) df0 = _filter_date_range(df0, cfg.start, cfg.end) df1, dup_removed = _dedup_by_date(df0) df2, outliers = _zscore_filter_on_returns(df1, cfg.zscore_threshold) if kind == "asset": payload["assets"][sym] = df2.to_dict(orient="records") payload["summary"]["assets"][sym] = _summary_stats(sym, df0, df2, outliers, dup_removed) else: payload["stocks"][sym] = df2.to_dict(orient="records") payload["summary"]["stocks"][sym] = _summary_stats(sym, df0, df2, outliers, dup_removed) payload["outliers"][sym] = outliers payload["monthly_means"][sym] = _monthly_means(df2) payload["logs"].extend(logs) payload["logs"].append( { "time": _now_iso(), "level": "INFO", "code": "SYMBOL_DONE", "message": "processed", "context": {"symbol": sym, "kind": kind, "n_raw": int(len(df0)), "n_clean": int(len(df2)), "dup_removed": int(dup_removed), "outliers": int(len(outliers))}, } ) return payload def main(): parser = argparse.ArgumentParser() parser.add_argument("--start", type=str, default=FetchConfig.start) parser.add_argument("--end", type=str, default=FetchConfig.end) parser.add_argument("--z", type=float, default=FetchConfig.zscore_threshold) parser.add_argument("--symbol", type=str, default="") parser.add_argument("--all", action="store_true", help="Download all symbols in universe") parser.add_argument("--kind", type=str, choices=["asset", "stock"], default="") parser.add_argument("--reset", action="store_true") args = parser.parse_args() cfg = FetchConfig(start=args.start, end=args.end, zscore_threshold=float(args.z)) out_dir = os.path.dirname(os.path.abspath(__file__)) os.makedirs(out_dir, exist_ok=True) json_path = os.path.join(out_dir, "data.json") if args.reset: payload = _new_payload(cfg) else: payload = _load_payload(json_path, cfg) assets, stocks = _default_symbols() if args.all: target_symbols = _universe_symbols() else: sym = args.symbol.strip().upper() if not sym: raise RuntimeError("必须提供 --symbol 或 --all") target_symbols = [sym] for sym in target_symbols: kind = args.kind if not kind: kind = "asset" if sym in set(assets) else "stock" print(f"Downloading {sym}...") payload = download_one(cfg, sym, kind, payload) _write_outputs(out_dir, payload) if __name__ == "__main__": main()