#!/usr/bin/env python3 """Replay a fixed SELL-pattern gate and fixed weights on selected stocks. This is a confirmation harness, not a search harness. It avoids choosing among gates or weights on the confirmation slice. """ from __future__ import annotations import argparse import html import json import sys from pathlib import Path from typing import Any import numpy as np import pandas as pd ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from scripts.evaluate_sell_pattern_regime_gates import ( # noqa: E402 _apply_gate, _build_gate_specs, _prediction_rates, ) from scripts.run_cached_daily_factor_overlay_report import DEFAULT_FACTORS, enrich_cached_events # noqa: E402 from scripts.search_multi_factor_weight_config import ( # noqa: E402 MultiFactorConfig, _now_iso, apply_multi_factor_config, ) from scripts.optimize_factor_weights import compute_event_metrics, metric_deltas # noqa: E402 DEFAULT_WEIGHTS = { "san_sheng_wu_nai_breakdown": 0.01, "downside_follow_through": 0.005, "weak_rebound_after_breakdown": 0.02, "high_volume_downside_follow_through": 0.05, } def _root_path(value: str | Path) -> Path: path = Path(value) return path if path.is_absolute() else ROOT / path def _split_csv(value: str) -> list[str]: return [item.strip() for item in value.split(",") if item.strip()] def _parse_weights(value: str) -> dict[str, float]: if not value.strip(): return dict(DEFAULT_WEIGHTS) out: dict[str, float] = {} for item in value.split(","): if not item.strip(): continue name, raw_weight = item.split("=", 1) out[name.strip()] = float(raw_weight.strip()) return out def _json_default(value: Any) -> Any: if isinstance(value, np.integer): return int(value) if isinstance(value, np.floating): return float(value) if isinstance(value, np.bool_): return bool(value) if hasattr(value, "isoformat"): return value.isoformat() raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") def _decision(deltas: dict[str, Any], *, min_signal_ratio: float, base_metrics: dict[str, Any], candidate_metrics: dict[str, Any]) -> dict[str, Any]: signal_ratio = candidate_metrics["signal_count"] / base_metrics["signal_count"] if base_metrics["signal_count"] else 0.0 checks = { "accuracy_delta": deltas["accuracy_delta_pp"] >= 0.0, "direction_accuracy_delta": deltas["direction_accuracy_delta_pp"] >= 0.0, "buy_precision_delta": deltas["buy_precision_delta_pp"] >= 0.0, "sell_precision_delta": deltas["sell_precision_delta_pp"] >= 0.0, "signal_ratio": signal_ratio >= min_signal_ratio, } return {"passed": all(checks.values()), "checks": checks, "signal_ratio": round(signal_ratio, 4)} def _select_stocks(events: pd.DataFrame, *, offset: int, limit: int) -> list[str]: stocks = sorted(events["stock"].astype(str).unique().tolist()) if offset: stocks = stocks[offset:] if limit and limit > 0: stocks = stocks[:limit] return stocks def confirm(args: argparse.Namespace) -> dict[str, Any]: factor_names = _split_csv(args.factors) or DEFAULT_FACTORS weights = _parse_weights(args.weights) missing_weights = [factor for factor in factor_names if factor not in weights] if missing_weights: raise ValueError(f"Missing weights for factors: {missing_weights}") events_path = _root_path(args.events_cache) all_events = pd.read_pickle(events_path) stocks = _select_stocks(all_events, offset=args.stock_offset, limit=args.limit) if not stocks: raise ValueError("No stocks selected for confirmation.") events = all_events[all_events["stock"].astype(str).isin(stocks)].reset_index(drop=True) enriched, covered_stocks = enrich_cached_events( events, factor_names=factor_names, months=args.months, workers=args.workers, ) gate_specs = {spec.name: spec for spec in _build_gate_specs(enriched)} if args.gate not in gate_specs: raise ValueError(f"Unknown gate {args.gate!r}; available: {sorted(gate_specs)}") gate_spec = gate_specs[args.gate] gated = _apply_gate(enriched, factor_names, gate_spec.mask) cfg = MultiFactorConfig(weights={factor: float(weights[factor]) for factor in factor_names}, hold_bias=float(args.hold_bias)) baseline_pred = gated["base_pred"].to_numpy(dtype=int) candidate_pred = apply_multi_factor_config(gated, cfg) baseline_metrics = compute_event_metrics(gated, baseline_pred) candidate_metrics = compute_event_metrics(gated, candidate_pred) deltas = metric_deltas(baseline_metrics, candidate_metrics) gate_active = int(gate_spec.mask.fillna(False).astype(bool).sum()) decision = _decision(deltas, min_signal_ratio=args.min_signal_ratio, base_metrics=baseline_metrics, candidate_metrics=candidate_metrics) payload = { "experiment": "fixed_sell_pattern_gate_confirmation", "generated_at": _now_iso(), "research_only": True, "production_defaults_modified": False, "events_cache": str(events_path), "stock_offset": args.stock_offset, "stock_limit": args.limit, "stocks": stocks, "covered_stocks": covered_stocks, "factor_names": factor_names, "fixed_gate": {"name": gate_spec.name, "description": gate_spec.description}, "fixed_config": {"weights": cfg.weights, "hold_bias": cfg.hold_bias}, "validation_design": { "mode": "fixed_gate_fixed_weights_replay", "selection_bias_guard": "Gate and weights are fixed before replay on the selected stock slice.", "months": args.months, }, "gate_coverage": { "active_events": gate_active, "active_rate": round(gate_active / len(gated) * 100.0, 4) if len(gated) else 0.0, }, "baseline": {"metrics": baseline_metrics, "prediction_rates": _prediction_rates(baseline_metrics)}, "candidate": {"metrics": candidate_metrics, "prediction_rates": _prediction_rates(candidate_metrics)}, "deltas": deltas, "decision": decision, } output_json = _root_path(args.output_json) output_json.parent.mkdir(parents=True, exist_ok=True) output_json.write_text(json.dumps(payload, indent=2, ensure_ascii=False, default=_json_default)) if args.output_html: render_html(payload, _root_path(args.output_html)) return payload def _fmt(value: Any, suffix: str = "") -> str: if isinstance(value, float): return f"{value:.4f}{suffix}" return f"{value}{suffix}" def render_html(payload: dict[str, Any], path: Path) -> None: base = payload["baseline"]["metrics"] cand = payload["candidate"]["metrics"] deltas = payload["deltas"] base_rates = payload["baseline"]["prediction_rates"] cand_rates = payload["candidate"]["prediction_rates"] decision = payload["decision"] decision_html = 'PASS' if decision["passed"] else 'FAIL' parts = [ "", '
', "Generated: {html.escape(payload['generated_at'])}
", f"Decision: {decision_html}
", f"Gate: {html.escape(payload['fixed_gate']['name'])} - {html.escape(payload['fixed_gate']['description'])}
Stock slice: offset {payload['stock_offset']}, limit {payload['stock_limit']}, covered {len(payload['covered_stocks'])}
", f"Gate coverage: {payload['gate_coverage']['active_events']} events / {_fmt(payload['gate_coverage']['active_rate'], '%')}
", "| Scope | Accuracy | Direction Acc | BUY Precision | SELL Precision | Coverage | Signals |
|---|---|---|---|---|---|---|
| Baseline | {_fmt(base['accuracy'], '%')} | {_fmt(base['direction_accuracy'], '%')} | {_fmt(base['buy_precision'], '%')} | {_fmt(base['sell_precision'], '%')} | {_fmt(base['coverage'] * 100.0, '%')} | {base['signal_count']} |
| Candidate | {_fmt(cand['accuracy'], '%')} | {_fmt(cand['direction_accuracy'], '%')} | {_fmt(cand['buy_precision'], '%')} | {_fmt(cand['sell_precision'], '%')} | {_fmt(cand['coverage'] * 100.0, '%')} | {cand['signal_count']} |
| Delta | {_fmt(deltas['accuracy_delta_pp'], 'pp')} | {_fmt(deltas['direction_accuracy_delta_pp'], 'pp')} | {_fmt(deltas['buy_precision_delta_pp'], 'pp')} | {_fmt(deltas['sell_precision_delta_pp'], 'pp')} | {_fmt(deltas['coverage_delta'] * 100.0, 'pp')} | {deltas['signal_count_delta']} |
| Scope | BUY Count | HOLD Count | SELL Count | BUY Rate | HOLD Rate | SELL Rate |
|---|---|---|---|---|---|---|
| Baseline | {base_rates['buy_count']} | {base_rates['hold_count']} | {base_rates['sell_count']} | {_fmt(base_rates['buy_rate'], '%')} | {_fmt(base_rates['hold_rate'], '%')} | {_fmt(base_rates['sell_rate'], '%')} |
| Candidate | {cand_rates['buy_count']} | {cand_rates['hold_count']} | {cand_rates['sell_count']} | {_fmt(cand_rates['buy_rate'], '%')} | {_fmt(cand_rates['hold_rate'], '%')} | {_fmt(cand_rates['sell_rate'], '%')} |
| Factor | Weight |
|---|---|
{html.escape(factor)} | {_fmt(weight)} |