from __future__ import annotations
|
|
import argparse
|
import csv
|
import json
|
import math
|
import time
|
from collections import Counter
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
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,
|
fetch_one_with_optional_timeout,
|
fnum,
|
local_daily_high,
|
time_hhmm,
|
to_baostock_code,
|
)
|
|
|
SOURCE_EVENTS = PACKAGE_ROOT / "minute_event_coverage_audit.csv"
|
FETCH_LEDGER = PACKAGE_ROOT / "minute_gap_5m_proxy_fetch_inventory.csv"
|
AUDIT_LEDGER = PACKAGE_ROOT / "minute_event_5m_proxy_audit.csv"
|
SUMMARY_JSON = PACKAGE_ROOT / "minute_event_5m_proxy_summary.json"
|
SUMMARY_MD = PACKAGE_ROOT / "minute_event_5m_proxy_summary.md"
|
|
|
def normalize_event_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 bar_time(value: str) -> str:
|
text = str(value)
|
if len(text) >= 12 and text[:8].isdigit():
|
return f"{text[8:10]}:{text[10:12]}:00"
|
return normalize_event_time(text)
|
|
|
def load_events() -> pd.DataFrame:
|
events = pd.read_csv(SOURCE_EVENTS, dtype=str, keep_default_na=False, encoding="utf-8-sig")
|
gaps = events[events["exact_time_found"] != "True"].copy()
|
gaps["event_time_norm"] = gaps["event_time"].map(normalize_event_time)
|
return gaps
|
|
|
def load_fetch_rows(gaps: pd.DataFrame) -> list[dict[str, str]]:
|
existing: dict[tuple[str, str], dict[str, str]] = {}
|
if FETCH_LEDGER.exists():
|
with FETCH_LEDGER.open("r", newline="", encoding="utf-8-sig") as f:
|
for row in csv.DictReader(f):
|
existing[(row["symbol"], row["event_trade_date"])] = row
|
|
rows: list[dict[str, str]] = []
|
unique = (
|
gaps[gaps["event_time_provided"] == "True"][["symbol", "event_trade_date"]]
|
.drop_duplicates()
|
.sort_values(["symbol", "event_trade_date"])
|
)
|
for row in unique.to_dict("records"):
|
key = (row["symbol"], row["event_trade_date"])
|
current = existing.get(key, {})
|
path = cache_path(row["symbol"], row["event_trade_date"])
|
if path.exists() and path.stat().st_size > 0:
|
try:
|
count = len(pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig"))
|
except Exception:
|
count = 0
|
if count > 0:
|
current = {
|
**current,
|
"fetch_status": "FETCH_OK",
|
"row_count": str(count),
|
"cache_path": str(path.relative_to(PACKAGE_ROOT)).replace("\\", "/"),
|
"error": "",
|
}
|
rows.append(
|
{
|
"symbol": row["symbol"],
|
"event_trade_date": row["event_trade_date"],
|
"baostock_code": to_baostock_code(row["symbol"]) or "",
|
"fetch_status": current.get("fetch_status", "PENDING"),
|
"row_count": current.get("row_count", "0"),
|
"cache_path": current.get("cache_path", ""),
|
"error": current.get("error", ""),
|
}
|
)
|
return rows
|
|
|
def write_fetch_rows(rows: list[dict[str, str]]) -> None:
|
keys = ["symbol", "event_trade_date", "baostock_code", "fetch_status", "row_count", "cache_path", "error"]
|
with FETCH_LEDGER.open("w", newline="", encoding="utf-8-sig") as f:
|
writer = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore")
|
writer.writeheader()
|
writer.writerows(rows)
|
|
|
def update_fetch_rows(rows: list[dict[str, str]], args: argparse.Namespace) -> int:
|
retry_statuses = {item.strip() for item in args.retry_status.split(",") if item.strip()}
|
if retry_statuses:
|
pending = [row for row in rows if row.get("fetch_status") in retry_statuses]
|
else:
|
pending = [row for row in rows if row.get("fetch_status") != "FETCH_OK"]
|
pending = pending[: args.fetch_limit]
|
attempted = 0
|
|
def fetch_row(row: dict[str, str]) -> tuple[dict[str, str], tuple[str, str, str, str]]:
|
return row, fetch_one_with_optional_timeout(row["symbol"], row["event_trade_date"], args.per_request_timeout)
|
|
if not pending:
|
return 0
|
if args.workers > 1:
|
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
futures = [pool.submit(fetch_row, row) for row in pending]
|
for future in as_completed(futures):
|
row, result = future.result()
|
status, cache, count, error = result
|
row["fetch_status"] = status
|
row["row_count"] = count
|
row["cache_path"] = cache
|
row["error"] = error
|
row["baostock_code"] = to_baostock_code(row["symbol"]) or ""
|
attempted += 1
|
write_fetch_rows(rows)
|
if args.sleep:
|
time.sleep(args.sleep)
|
else:
|
for row in pending:
|
status, cache, count, error = fetch_one_with_optional_timeout(
|
row["symbol"], row["event_trade_date"], args.per_request_timeout
|
)
|
row["fetch_status"] = status
|
row["row_count"] = count
|
row["cache_path"] = cache
|
row["error"] = error
|
row["baostock_code"] = to_baostock_code(row["symbol"]) or ""
|
attempted += 1
|
write_fetch_rows(rows)
|
if args.sleep:
|
time.sleep(args.sleep)
|
return attempted
|
|
|
def load_5m_frame(path_text: str) -> pd.DataFrame:
|
if not path_text:
|
return pd.DataFrame()
|
path = PACKAGE_ROOT / path_text
|
if not path.exists():
|
return pd.DataFrame()
|
df = pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig")
|
if df.empty:
|
return df
|
df["bar_time"] = df["time"].map(bar_time)
|
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("bar_time")
|
|
|
def scaled_values(df: pd.DataFrame, symbol: str, trade_date: str, daily_cache: dict[str, pd.DataFrame]) -> tuple[float | None, float | None, str]:
|
if df.empty or "high_num" not in df.columns:
|
return None, None, "NO_5M_HIGH"
|
raw_high = float(df["high_num"].max())
|
local_high = local_daily_high(symbol, trade_date, daily_cache)
|
if local_high is None or raw_high <= 0:
|
return None, None, "RAW_5M_NO_LOCAL_DAILY_SCALE"
|
return raw_high, local_high / raw_high, "BAOSTOCK_5M_SCALED_TO_LOCAL_DAILY_HIGH"
|
|
|
def choose_proxy_bar(df: pd.DataFrame, event_time: str) -> pd.Series | None:
|
if df.empty or not event_time:
|
return None
|
rows = df[df["bar_time"] >= event_time]
|
if rows.empty:
|
rows = df.tail(1)
|
return rows.iloc[0] if not rows.empty else None
|
|
|
def build_audit(gaps: pd.DataFrame, fetch_rows: list[dict[str, str]]) -> dict[str, Any]:
|
fetch_by_key = {(row["symbol"], row["event_trade_date"]): row for row in fetch_rows}
|
frame_cache: dict[str, pd.DataFrame] = {}
|
daily_cache: dict[str, pd.DataFrame] = {}
|
out: list[dict[str, Any]] = []
|
|
for row in gaps.to_dict("records"):
|
symbol = row["symbol"]
|
trade_date = row["event_trade_date"]
|
event_time = row.get("event_time_norm", "")
|
fetch = fetch_by_key.get((symbol, trade_date), {})
|
path_text = fetch.get("cache_path", "")
|
df = frame_cache.get(path_text)
|
if df is None:
|
df = load_5m_frame(path_text)
|
frame_cache[path_text] = df
|
raw_day_high, scale, price_source = scaled_values(df, symbol, trade_date, daily_cache)
|
proxy_bar = choose_proxy_bar(df, event_time) if row.get("event_time_provided") == "True" else None
|
|
proxy_status = "NO_EVENT_TIME"
|
values: dict[str, Any] = {}
|
if row.get("event_time_provided") == "True":
|
if fetch.get("fetch_status") != "FETCH_OK":
|
proxy_status = "NO_5M_DATA"
|
elif proxy_bar is None:
|
proxy_status = "NO_5M_BAR"
|
else:
|
proxy_status = "PROXY_5M_BAR_FOUND"
|
values = {
|
"proxy_5m_bar_time": proxy_bar.get("bar_time", ""),
|
"proxy_5m_raw_open": proxy_bar.get("open", ""),
|
"proxy_5m_raw_high": proxy_bar.get("high", ""),
|
"proxy_5m_raw_low": proxy_bar.get("low", ""),
|
"proxy_5m_raw_close": proxy_bar.get("close", ""),
|
"proxy_5m_volume": proxy_bar.get("volume", ""),
|
}
|
if scale is not None:
|
for col in ["open", "high", "low", "close"]:
|
num = fnum(proxy_bar.get(col, ""))
|
values[f"proxy_5m_scaled_{col}"] = "" if num is None else num * scale
|
|
out.append(
|
{
|
"event_source": row.get("event_source", ""),
|
"event_id": row.get("event_id", ""),
|
"case_id": row.get("case_id", ""),
|
"symbol": symbol,
|
"event_trade_date": trade_date,
|
"event_time": row.get("event_time", ""),
|
"action_or_signal": row.get("action_or_signal", ""),
|
"signal_type": row.get("signal_type", ""),
|
"original_coverage_status": row.get("coverage_status", ""),
|
"minute_file_status": row.get("minute_file_status", ""),
|
"public_5m_fetch_status": fetch.get("fetch_status", ""),
|
"public_5m_row_count": fetch.get("row_count", ""),
|
"public_5m_cache_path": path_text,
|
"proxy_status": proxy_status,
|
"comparison_price_source": price_source,
|
"public_5m_full_day_high_raw": raw_day_high if raw_day_high is not None else "",
|
"public_5m_to_local_daily_scale": scale if scale is not None else "",
|
**values,
|
}
|
)
|
|
keys: list[str] = []
|
seen: set[str] = set()
|
for row in out:
|
for key in row:
|
if key not in seen:
|
seen.add(key)
|
keys.append(key)
|
with AUDIT_LEDGER.open("w", newline="", encoding="utf-8-sig") as f:
|
writer = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore")
|
writer.writeheader()
|
writer.writerows(out)
|
|
summary = {
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
"input_gap_events": len(gaps),
|
"gap_events_with_time": int((gaps["event_time_provided"] == "True").sum()),
|
"unique_symbol_dates_requested": len(fetch_rows),
|
"fetch_status_counts": dict(Counter(row.get("fetch_status", "") for row in fetch_rows)),
|
"proxy_status_counts": dict(Counter(str(row["proxy_status"]) for row in out)),
|
"boundaries": [
|
"This is a 5-minute public K-line proxy for events whose original 1-minute evidence was missing or not exact.",
|
"A proxy bar is the first 5-minute bar with period-end time >= the event time; it is not a precise 1-minute replacement.",
|
"Raw Baostock front-adjusted prices are scaled to the local daily front-adjusted high when local daily data is available.",
|
"NO_EVENT_TIME rows cannot be mapped to an exact proxy bar without a rule-specific timestamp.",
|
],
|
}
|
SUMMARY_JSON.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
SUMMARY_MD.write_text(
|
"\n".join(
|
[
|
"# Minute Gap 5m Proxy Summary",
|
"",
|
f"- generated_at: {summary['generated_at']}",
|
f"- input_gap_events: {summary['input_gap_events']}",
|
f"- gap_events_with_time: {summary['gap_events_with_time']}",
|
f"- unique_symbol_dates_requested: {summary['unique_symbol_dates_requested']}",
|
f"- fetch_status_counts: {json.dumps(summary['fetch_status_counts'], ensure_ascii=False)}",
|
f"- proxy_status_counts: {json.dumps(summary['proxy_status_counts'], ensure_ascii=False)}",
|
"",
|
"## Boundaries",
|
*[f"- {item}" for item in summary["boundaries"]],
|
"",
|
]
|
),
|
encoding="utf-8",
|
)
|
return summary
|
|
|
def write_manifest() -> None:
|
rows = []
|
for path in sorted(PACKAGE_ROOT.rglob("*")):
|
if path.is_file():
|
rows.append({"path": str(path.relative_to(PACKAGE_ROOT)).replace("\\", "/"), "bytes": path.stat().st_size})
|
with (PACKAGE_ROOT / "manifest.csv").open("w", newline="", encoding="utf-8-sig") as f:
|
writer = csv.DictWriter(f, fieldnames=["path", "bytes"])
|
writer.writeheader()
|
writer.writerows(rows)
|
|
|
def main() -> None:
|
parser = argparse.ArgumentParser()
|
parser.add_argument("--fetch-limit", type=int, default=100)
|
parser.add_argument("--workers", type=int, default=2)
|
parser.add_argument("--per-request-timeout", type=int, default=120)
|
parser.add_argument("--sleep", type=float, default=0.0)
|
parser.add_argument("--retry-status", default="")
|
parser.add_argument("--rebuild-only", action="store_true")
|
args = parser.parse_args()
|
|
gaps = load_events()
|
fetch_rows = load_fetch_rows(gaps)
|
attempted = 0
|
if not args.rebuild_only and args.fetch_limit != 0:
|
attempted = update_fetch_rows(fetch_rows, args)
|
else:
|
write_fetch_rows(fetch_rows)
|
summary = build_audit(gaps, fetch_rows)
|
write_manifest()
|
print(json.dumps({"attempted": attempted, **summary}, ensure_ascii=False, indent=2))
|
|
|
if __name__ == "__main__":
|
main()
|