Files
tick-stock-panel/backend/scripts/probe_tickflow_pro.py
T
dunmin1980andCursor cafa03fb52 feat: add TickFlow Pro Phase 1 probe scaffold with off-peak gate
Dry-run by default; live calls require a key and wait until 16:00 Asia/Shanghai unless --force, so Stage A off-peak probing stays intentional.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 09:36:55 +08:00

221 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""TickFlow Pro Phase 1 probe (sanitized).
Default is dry-run (no network). Live probe requires TICKFLOW_API_KEY and
should run after 16:00 Asia/Shanghai unless --force is set (Stage A off-peak).
Never prints API keys. Writes reports under reports/tickflow_pro_probe/.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
SHANGHAI = ZoneInfo("Asia/Shanghai")
GOLDEN = ("000403.SZ", "600489.SH", "300059.SZ")
OFFPEAK_HOUR = 16
@dataclass
class ProbeGate:
dry_run: bool
force: bool
now_hour: int
has_key: bool
allowed: bool
reason: str
def evaluate_gate(*, dry_run: bool, force: bool, has_key: bool, now: datetime | None = None) -> ProbeGate:
now = now or datetime.now(SHANGHAI)
hour = now.hour
if dry_run:
return ProbeGate(True, force, hour, has_key, True, "dry_run")
if not has_key:
return ProbeGate(False, force, hour, False, False, "missing_TICKFLOW_API_KEY")
if hour < OFFPEAK_HOUR and not force:
return ProbeGate(False, force, hour, True, False, f"before_{OFFPEAK_HOUR:02d}00_use_--force_or_wait")
return ProbeGate(False, force, hour, True, True, "live_ok")
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _sanitize_sample(obj: object) -> object:
"""Keep structure; drop long arrays and obvious secret-like strings."""
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
lk = str(k).lower()
if any(s in lk for s in ("key", "token", "secret", "password", "authorization")):
out[k] = "***"
else:
out[k] = _sanitize_sample(v)
return out
if isinstance(obj, list):
if len(obj) > 3:
return [_sanitize_sample(x) for x in obj[:3]] + [f"...(+{len(obj) - 3} more)"]
return [_sanitize_sample(x) for x in obj]
if isinstance(obj, str) and len(obj) > 120:
return obj[:120] + "..."
return obj
def run_dry_run(out_dir: Path) -> dict:
out_dir.mkdir(parents=True, exist_ok=True)
report = {
"mode": "dry_run",
"as_of": datetime.now(SHANGHAI).isoformat(timespec="seconds"),
"symbols": list(GOLDEN),
"planned_checks": [
"quote.batch",
"kline.daily.batch",
"kline.minute.by_symbol",
"adj_factor",
"rate_limit_observation",
],
"safety": {
"SAFETY_RPM_FACTOR": 0.8,
"offpeak_hour": OFFPEAK_HOUR,
"note": "Live probe must not overlap Gold Stage A peak window without --force.",
},
"status": "READY_FOR_LIVE",
}
(out_dir / "probe_summary.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
(out_dir / "probe_summary.md").write_text(
"# TickFlow Pro probe (dry-run)\n\n"
f"- as_of: {report['as_of']}\n"
f"- symbols: {', '.join(GOLDEN)}\n"
"- status: READY_FOR_LIVE\n"
f"- live command: `TICKFLOW_API_KEY=... python scripts/probe_tickflow_pro.py --live` "
f"(after {OFFPEAK_HOUR}:00 or with --force)\n"
)
return report
def run_live(out_dir: Path) -> dict:
"""Minimal live smoke: capability detect + one daily pull for golden symbols."""
out_dir.mkdir(parents=True, exist_ok=True)
# Prefer env for one-shot CLI; secrets_store if app context available.
key = os.environ.get("TICKFLOW_API_KEY", "").strip()
if not key:
try:
from app.secrets_store import get_tickflow_key # type: ignore
key = (get_tickflow_key() or "").strip()
except Exception:
key = ""
if not key:
raise SystemExit("missing TICKFLOW_API_KEY")
from tickflow import TickFlow
from app.tickflow.client import PAID_ENDPOINT, _base_url
from app.tickflow.rate_limits import SAFETY_RPM_FACTOR, apply_safety_rpm
base = _base_url() or PAID_ENDPOINT
tf = TickFlow(api_key=key, base_url=base)
samples: dict[str, object] = {}
errors: list[str] = []
# daily batch for 3 symbols — do not log key
try:
raw = tf.kline.daily(symbols=list(GOLDEN), limit=5) # type: ignore[attr-defined]
samples["kline.daily"] = _sanitize_sample(raw if not hasattr(raw, "to_dict") else raw.to_dict())
except Exception as exc: # noqa: BLE001
errors.append(f"kline.daily: {type(exc).__name__}")
try:
raw = tf.quote.get(symbols=list(GOLDEN)) # type: ignore[attr-defined]
samples["quote"] = _sanitize_sample(raw if not hasattr(raw, "to_dict") else raw.to_dict())
except Exception as exc: # noqa: BLE001
# SDK surface varies; record type only
errors.append(f"quote: {type(exc).__name__}: try alternate API in follow-up")
report = {
"mode": "live",
"as_of": datetime.now(SHANGHAI).isoformat(timespec="seconds"),
"endpoint": base,
"symbols": list(GOLDEN),
"safety_rpm_factor": SAFETY_RPM_FACTOR,
"example_budget_daily_batch_rpm": apply_safety_rpm(60),
"samples": samples,
"errors": errors,
"status": "LIVE_PARTIAL" if errors else "LIVE_OK",
}
(out_dir / "probe_summary.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
(out_dir / "probe_summary.md").write_text(
"# TickFlow Pro probe (live)\n\n"
f"- as_of: {report['as_of']}\n"
f"- status: {report['status']}\n"
f"- errors: {len(errors)}\n"
"- samples sanitized; no API key written.\n"
)
return report
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--live", action="store_true", help="Call TickFlow APIs (needs key)")
parser.add_argument("--force", action="store_true", help="Allow live probe before 16:00 Asia/Shanghai")
parser.add_argument(
"--out",
type=Path,
default=None,
help="Output directory (default: <repo>/reports/tickflow_pro_probe/<timestamp>)",
)
args = parser.parse_args(argv)
dry_run = not args.live
has_key = bool(os.environ.get("TICKFLOW_API_KEY", "").strip())
gate = evaluate_gate(dry_run=dry_run, force=args.force, has_key=has_key)
# #region agent log
try:
_dbg = Path(__file__).resolve().parents[2] / ".cursor" / "debug-976372.log"
_dbg.parent.mkdir(parents=True, exist_ok=True)
_dbg.open("a").write(
json.dumps(
{
"sessionId": "976372",
"runId": "phase1-scaffold",
"hypothesisId": "H1",
"location": "probe_tickflow_pro.py:main",
"message": "probe gate decision",
"data": asdict(gate),
"timestamp": int(datetime.now(SHANGHAI).timestamp() * 1000),
},
ensure_ascii=False,
)
+ "\n"
)
except Exception:
pass
# #endregion
print(json.dumps(asdict(gate), ensure_ascii=False))
if not gate.allowed:
return 2
stamp = datetime.now(SHANGHAI).strftime("%Y%m%d_%H%M%S")
out_dir = args.out or (_repo_root() / "reports" / "tickflow_pro_probe" / stamp)
if dry_run:
report = run_dry_run(out_dir)
else:
# Ensure app imports resolve when run as script
root = _repo_root() / "backend"
if str(root) not in sys.path:
sys.path.insert(0, str(root))
report = run_live(out_dir)
print(json.dumps({"out": str(out_dir), "status": report.get("status")}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())