#!/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 = [ "", '', "Fixed SELL Pattern Gate Confirmation", "", "

Fixed SELL Pattern Gate Confirmation

", f"

Generated: {html.escape(payload['generated_at'])}

", f"

Decision: {decision_html}

", f"

Gate: {html.escape(payload['fixed_gate']['name'])} - {html.escape(payload['fixed_gate']['description'])}

", f"

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'], '%')}

", "

Metrics

", "", f"", f"", f"", "
ScopeAccuracyDirection AccBUY PrecisionSELL PrecisionCoverageSignals
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']}
", "

BUY / HOLD / SELL Prediction Rate

", "", f"", f"", "
ScopeBUY CountHOLD CountSELL CountBUY RateHOLD RateSELL 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'], '%')}
", "

Fixed Weights

", "", ] for factor, weight in payload["fixed_config"]["weights"].items(): parts.append(f"") parts.extend(["
FactorWeight
{html.escape(factor)}{_fmt(weight)}
", ""]) path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(parts)) def main() -> int: parser = argparse.ArgumentParser(description="Replay a fixed SELL-pattern gate and fixed weights on a selected stock slice.") parser.add_argument("--events-cache", default="docs/validation_runs/factor44_split_problem_v1_buy_factor_interaction_adaptive_macro_1000_events.pkl") parser.add_argument("--factors", default=",".join(DEFAULT_FACTORS)) parser.add_argument("--weights", default=",".join(f"{factor}={weight}" for factor, weight in DEFAULT_WEIGHTS.items())) parser.add_argument("--gate", default="high_volume_upper_shadow_sell") parser.add_argument("--stock-offset", type=int, default=1000) parser.add_argument("--limit", type=int, default=0, help="0 means all stocks after offset.") parser.add_argument("--months", type=int, default=36) parser.add_argument("--workers", type=int, default=12) parser.add_argument("--hold-bias", type=float, default=0.0) parser.add_argument("--min-signal-ratio", type=float, default=0.70) parser.add_argument("--output-json", default="docs/validation_runs/fixed_sell_pattern_gate_heldout_20260611.json") parser.add_argument("--output-html", default="docs/validation_runs/fixed_sell_pattern_gate_heldout_20260611.html") args = parser.parse_args() payload = confirm(args) print(f"Stocks selected: {len(payload['stocks'])}") print(f"Stocks covered: {len(payload['covered_stocks'])}") print(f"Decision: {'PASS' if payload['decision']['passed'] else 'FAIL'}") print(f"Baseline metrics: {payload['baseline']['metrics']}") print(f"Candidate metrics: {payload['candidate']['metrics']}") print(f"Deltas: {payload['deltas']}") print(f"Saved JSON -> {_root_path(args.output_json)}") if args.output_html: print(f"Saved HTML -> {_root_path(args.output_html)}") return 0 if __name__ == "__main__": raise SystemExit(main())