from __future__ import annotations import argparse import csv import json import math from collections import Counter from datetime import datetime from pathlib import Path from typing import Any import pandas as pd from retry_public_5m_proxy_baostock_per_request import PACKAGE_ROOT, cache_path, date_key, fnum, local_daily_high, time_hhmm STRICT_ROOT = PACKAGE_ROOT.parent / "RUN-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260609-001" LOCAL_MINUTE_ROOT = Path("E:/quant/2023_front_m") LOCAL_DAILY_ROOT = Path("E:/quant/a_share_daily_front_20230101_20260508_complete/daily") LOT_LEDGER = STRICT_ROOT / "strict_position_lot_ledger.csv" SELL_SIGNAL_LEDGER = STRICT_ROOT / "strict_sell_signal_ledger.csv" COVERAGE_CSV = PACKAGE_ROOT / "open_volume_stall_scan_coverage.csv" CANDIDATE_CSV = PACKAGE_ROOT / "open_volume_stall_independent_candidates.csv" SUMMARY_JSON = PACKAGE_ROOT / "open_volume_stall_scan_summary.json" SUMMARY_MD = PACKAGE_ROOT / "open_volume_stall_scan_summary.md" PROGRESS_JSON = PACKAGE_ROOT / "open_volume_stall_scan_progress.json" def safe_float(value: Any) -> float: try: if pd.isna(value) or str(value).strip() == "": return math.nan return float(value) except Exception: return math.nan def market_code(symbol: str) -> tuple[str, str]: code, market = symbol.split(".") return code, market def minute_path(symbol: str) -> Path: code, market = market_code(symbol) return LOCAL_MINUTE_ROOT / market / f"price_{code}.csv" def daily_path(symbol: str) -> Path: return LOCAL_DAILY_ROOT / f"{symbol}.csv" def normalize_time(value: str) -> str: text = str(value).strip() if not text: return "" if " " in text: text = text.split()[-1] parts = text.split(":") if len(parts) == 2: return f"{int(parts[0]):02d}:{int(parts[1]):02d}:00" if len(parts) >= 3: return f"{int(parts[0]):02d}:{int(parts[1]):02d}:{int(float(parts[2])):02d}" return text def load_daily(symbol: str, cache: dict[str, pd.DataFrame]) -> pd.DataFrame: if symbol in cache: return cache[symbol] path = daily_path(symbol) if not path.exists(): df = pd.DataFrame() else: df = pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig") df["date_key"] = df["trade_date"].map(date_key) for col in ["open", "high", "low", "close", "preClose", "volume"]: if col in df.columns: df[f"{col}_num"] = pd.to_numeric(df[col], errors="coerce") df = df.sort_values("date_key") cache[symbol] = df return df def load_minute(symbol: str, cache: dict[str, pd.DataFrame]) -> pd.DataFrame: if symbol in cache: return cache[symbol] path = minute_path(symbol) if not path.exists() or path.stat().st_size == 0: df = pd.DataFrame() else: df = pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig") df["date_key"] = df["timetag"].str.slice(0, 8) df["trade_time"] = df["timetag"].str.slice(9, 17) df = df.rename(columns={"volumn": "volume"}) for col in ["open", "high", "low", "close", "volume", "amount"]: if col in df.columns: df[f"{col}_num"] = pd.to_numeric(df[col], errors="coerce") df = df.sort_values(["date_key", "trade_time"]) cache[symbol] = df return df def trade_dates_for_lot(daily: pd.DataFrame, start_date: str, end_date: str, max_open_days: int) -> list[str]: if daily.empty: return [] start = date_key(start_date) end = date_key(end_date) if end_date else str(daily["date_key"].max()) rows = daily[(daily["date_key"] >= start) & (daily["date_key"] <= end)]["date_key"].tolist() return rows[:max_open_days] if not end_date else rows def previous_dates(daily: pd.DataFrame, d: str, n: int) -> list[str]: dates = daily[daily["date_key"] < d]["date_key"].tolist() return dates[-n:] def daily_prev_close(daily: pd.DataFrame, d: str) -> float: prevs = previous_dates(daily, d, 1) if not prevs: return math.nan row = daily[daily["date_key"] == prevs[-1]] return safe_float(row.iloc[0].get("close_num", math.nan)) if not row.empty else math.nan def scale_for_day(symbol: str, d: str, day_high: float, daily_cache: dict[str, pd.DataFrame]) -> tuple[float, str]: daily_high = local_daily_high(symbol, f"{d[:4]}-{d[4:6]}-{d[6:8]}", daily_cache) if daily_high is None or not day_high or math.isnan(day_high) or day_high <= 0: return 1.0, "RAW_PRICE_NO_SCALE" return daily_high / day_high, "SCALED_TO_LOCAL_DAILY_HIGH" def exact_1m_metrics(symbol: str, d: str, daily: pd.DataFrame, minute: pd.DataFrame, daily_cache: dict[str, pd.DataFrame]) -> dict[str, Any]: if minute.empty: return {"scan_status": "NO_LOCAL_1M_FILE"} day = minute[minute["date_key"] == d] if day.empty: return {"scan_status": "NO_LOCAL_1M_DATE"} first = day.iloc[0] prev_close = daily_prev_close(daily, d) if math.isnan(prev_close) or prev_close <= 0: return {"scan_status": "NO_DAILY_PREV_CLOSE"} scale, source = scale_for_day(symbol, d, safe_float(day["high_num"].max()), daily_cache) first_close_raw = safe_float(first.get("close_num")) first_close = first_close_raw * scale if not math.isnan(first_close_raw) else math.nan first_gain = first_close / prev_close - 1 if not math.isnan(first_close) else math.nan vols = [] for pd_key in previous_dates(daily, d, 5): prev_day = minute[minute["date_key"] == pd_key] if not prev_day.empty: vols.append(safe_float(prev_day.iloc[0].get("volume_num"))) vols = [v for v in vols if not math.isnan(v) and v > 0] avg_first_vol = float(pd.Series(vols).mean()) if vols else math.nan first_vol = safe_float(first.get("volume_num")) vol_ratio = first_vol / avg_first_vol if avg_first_vol and not math.isnan(avg_first_vol) and avg_first_vol > 0 else math.nan hit = (not math.isnan(vol_ratio)) and vol_ratio >= 10 and (not math.isnan(first_gain)) and 0.01 <= first_gain <= 0.02 return { "scan_status": "SCANNED_EXACT_1M", "scan_basis": "EXACT_1M", "price_source": source, "first_bar_time": first.get("trade_time", ""), "first_close_raw": first_close_raw, "first_close_scaled": first_close, "prev_close_daily": prev_close, "first_gain_pct": first_gain, "first_volume": first_vol, "avg_previous_first_volume": avg_first_vol, "open_volume_ratio": vol_ratio, "stall_hit": hit, } def load_5m_day(symbol: str, d: str) -> pd.DataFrame: path = cache_path(symbol, f"{d[:4]}-{d[4:6]}-{d[6:8]}") if not path.exists() or path.stat().st_size == 0: return pd.DataFrame() df = pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig") if df.empty: return df df["hhmm"] = df["time"].map(time_hhmm) for col in ["open", "high", "low", "close", "volume", "amount"]: if col in df.columns: df[f"{col}_num"] = pd.to_numeric(df[col], errors="coerce") return df.sort_values("hhmm") def proxy_5m_metrics(symbol: str, d: str, daily: pd.DataFrame, daily_cache: dict[str, pd.DataFrame]) -> dict[str, Any]: day = load_5m_day(symbol, d) if day.empty: return {"scan_status": "NO_5M_PROXY_CACHE"} prev_close = daily_prev_close(daily, d) if math.isnan(prev_close) or prev_close <= 0: return {"scan_status": "NO_DAILY_PREV_CLOSE"} first = day.iloc[0] scale, source = scale_for_day(symbol, d, safe_float(day["high_num"].max()), daily_cache) first_close_raw = safe_float(first.get("close_num")) first_close = first_close_raw * scale if not math.isnan(first_close_raw) else math.nan first_gain = first_close / prev_close - 1 if not math.isnan(first_close) else math.nan vols = [] missing_prev = 0 for pd_key in previous_dates(daily, d, 5): prev_day = load_5m_day(symbol, pd_key) if prev_day.empty: missing_prev += 1 else: vols.append(safe_float(prev_day.iloc[0].get("volume_num"))) vols = [v for v in vols if not math.isnan(v) and v > 0] if not vols: return {"scan_status": "NO_PROXY_PREVIOUS_5M_VOLUME", "scan_basis": "PROXY_5M", "missing_previous_5m_days": missing_prev} avg_first_vol = float(pd.Series(vols).mean()) first_vol = safe_float(first.get("volume_num")) vol_ratio = first_vol / avg_first_vol if avg_first_vol > 0 else math.nan hit = (not math.isnan(vol_ratio)) and vol_ratio >= 10 and (not math.isnan(first_gain)) and 0.01 <= first_gain <= 0.02 return { "scan_status": "SCANNED_PROXY_5M", "scan_basis": "PROXY_5M", "price_source": source, "first_bar_time": first.get("hhmm", ""), "first_close_raw": first_close_raw, "first_close_scaled": first_close, "prev_close_daily": prev_close, "first_gain_pct": first_gain, "first_volume": first_vol, "avg_previous_first_volume": avg_first_vol, "open_volume_ratio": vol_ratio, "stall_hit": hit, "missing_previous_5m_days": missing_prev, } def write_csv(path: Path, rows: list[dict[str, Any]]) -> None: keys: list[str] = [] seen: set[str] = set() for row in rows: for key in row: if key not in seen: seen.add(key) keys.append(key) with path.open("w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore") writer.writeheader() writer.writerows(rows) def write_progress(payload: dict[str, Any]) -> None: PROGRESS_JSON.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--max-open-lot-days", type=int, default=10) parser.add_argument("--use-proxy-5m", action="store_true") args = parser.parse_args() lots = pd.read_csv(LOT_LEDGER, dtype=str, keep_default_na=False, encoding="utf-8-sig") signals = pd.read_csv(SELL_SIGNAL_LEDGER, dtype=str, keep_default_na=False, encoding="utf-8-sig") signal_by_id = signals.set_index("signal_id").to_dict("index") if "signal_id" in signals.columns else {} daily_cache: dict[str, pd.DataFrame] = {} coverage: list[dict[str, Any]] = [] candidates: list[dict[str, Any]] = [] grouped = list(lots.groupby("symbol", sort=True)) for symbol_index, (symbol, symbol_lots) in enumerate(grouped, start=1): daily = load_daily(symbol, daily_cache) minute = load_minute(symbol, {}) for lot in symbol_lots.to_dict("records"): dates = trade_dates_for_lot( daily, lot["sellable_from_trade_date"], lot.get("exit_trade_date", ""), args.max_open_lot_days, ) exit_time = normalize_time(lot.get("exit_time", "")) exit_signal = signal_by_id.get(lot.get("exit_signal_id", ""), {}) for d in dates: metrics = exact_1m_metrics(symbol, d, daily, minute, daily_cache) if metrics.get("scan_status", "").startswith("NO_") and args.use_proxy_5m: metrics = proxy_5m_metrics(symbol, d, daily, daily_cache) first_time = normalize_time(str(metrics.get("first_bar_time", ""))) if ( lot.get("exit_trade_date") and date_key(lot["exit_trade_date"]) == d and exit_time and first_time and exit_time <= first_time ): metrics = {"scan_status": "SKIP_EXIT_BEFORE_OPEN_BAR", **metrics} base = { "strict_lot_id": lot.get("strict_lot_id", ""), "source_lot_id": lot.get("source_lot_id", ""), "case_id": lot.get("case_id", ""), "symbol": symbol, "scan_trade_date": f"{d[:4]}-{d[4:6]}-{d[6:8]}", "entry_trade_date": lot.get("entry_trade_date", ""), "sellable_from_trade_date": lot.get("sellable_from_trade_date", ""), "exit_trade_date": lot.get("exit_trade_date", ""), "exit_time": lot.get("exit_time", ""), "existing_exit_signal_id": lot.get("exit_signal_id", ""), "existing_exit_signal_type": exit_signal.get("signal_type", ""), } row = {**base, **metrics} coverage.append(row) if str(row.get("stall_hit")) == "True": candidates.append(row) if symbol_index % 50 == 0: write_progress( { "status": "RUNNING", "symbol_index": symbol_index, "symbol_count": len(grouped), "coverage_rows": len(coverage), "candidate_rows": len(candidates), "generated_at": datetime.now().isoformat(timespec="seconds"), } ) write_csv(COVERAGE_CSV, coverage) write_csv(CANDIDATE_CSV, candidates) summary = { "generated_at": datetime.now().isoformat(timespec="seconds"), "lot_rows": len(lots), "coverage_rows": len(coverage), "candidate_rows": len(candidates), "coverage_status_counts": dict(Counter(str(row.get("scan_status", "")) for row in coverage)), "candidate_basis_counts": dict(Counter(str(row.get("scan_basis", "")) for row in candidates)), "threshold": "open_volume_ratio >= 10 and 0.01 <= first_gain_pct <= 0.02", "boundaries": [ "EXACT_1M rows use local 1-minute files and daily previous close, with prices scaled to local daily high when possible.", "PROXY_5M rows use cached public 5-minute bars and are not strict opening-one-minute evidence.", "This is an independent scan; candidate rows indicate possible omissions that require chart/manual review before changing V1 ledgers.", ], } SUMMARY_JSON.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") SUMMARY_MD.write_text( "\n".join( [ "# Open Volume Stall Independent Scan", "", f"- generated_at: {summary['generated_at']}", f"- lot_rows: {summary['lot_rows']}", f"- coverage_rows: {summary['coverage_rows']}", f"- candidate_rows: {summary['candidate_rows']}", f"- coverage_status_counts: {json.dumps(summary['coverage_status_counts'], ensure_ascii=False)}", f"- candidate_basis_counts: {json.dumps(summary['candidate_basis_counts'], ensure_ascii=False)}", f"- threshold: {summary['threshold']}", "", "## Boundaries", *[f"- {item}" for item in summary["boundaries"]], "", ] ), encoding="utf-8", ) write_progress({"status": "FINISHED", **summary}) print(json.dumps(summary, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()