Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """C21: dry-run Old Wang style rules. | |
| Rules under test: | |
| - 三陽開泰 / 三聲無奈 | |
| - 投信看 10MA, 外資看 20MA | |
| - 爆大量 K 棒高低點防守 | |
| - 跳空缺口守住 / 回補 | |
| No production FEATURE_COLUMNS are modified by this script. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import sys | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parent.parent | |
| sys.path.insert(0, str(ROOT)) | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv(ROOT / ".env") | |
| except ImportError: | |
| pass | |
| import warnings | |
| warnings.filterwarnings("ignore") | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.ensemble import RandomForestClassifier | |
| from models.predictor import FEATURE_COLUMNS, _build_features | |
| from scripts.backtest_c20_targeted_signal_gates import _safe_div, _true_range, _zscore_prior, fetch_validation_df | |
| from scripts.improvement_harness import ( | |
| DEFAULT_STOCKS, | |
| EXTENDED_STOCKS, | |
| LABEL_HORIZON, | |
| MIN_TRAIN, | |
| RF_PARAMS, | |
| STEP, | |
| build_triple_barrier_labels, | |
| compute_metrics, | |
| ) | |
| PASS_PER_STOCK_ACCURACY_DELTA = 3.0 | |
| MIN_SIGNAL_RATIO = 0.70 | |
| OLDWANG_FEATURES = [ | |
| "oldwang_triple_bull", | |
| "oldwang_triple_bear", | |
| "oldwang_ma5_hold", | |
| "oldwang_trust_ma10_guard", | |
| "oldwang_trust_ma10_broken", | |
| "oldwang_foreign_ma20_guard", | |
| "oldwang_foreign_ma20_broken", | |
| "oldwang_volume_spike", | |
| "oldwang_volume_high_break", | |
| "oldwang_volume_low_guard", | |
| "oldwang_volume_low_break", | |
| "oldwang_gap_guard", | |
| "oldwang_gap_filled", | |
| "oldwang_bull_score", | |
| "oldwang_bear_score", | |
| ] | |
| STRATEGIES = [ | |
| "oldwang_features", | |
| "oldwang_veto", | |
| "oldwang_long_gate", | |
| "oldwang_features_veto", | |
| ] | |
| def _rolling_recent_flag(flag: pd.Series, window: int) -> pd.Series: | |
| return flag.astype(float).rolling(window, min_periods=1).max().fillna(0.0) | |
| def _last_event_level(level: pd.Series, event: pd.Series) -> pd.Series: | |
| return level.where(event).shift(1).ffill() | |
| def build_oldwang_features(df: pd.DataFrame) -> pd.DataFrame: | |
| out = pd.DataFrame(index=df.index) | |
| open_ = df["open"].astype(float) | |
| high = df["high"].astype(float) | |
| low = df["low"].astype(float) | |
| close = df["close"].astype(float) | |
| volume = df.get("volume", pd.Series(0.0, index=df.index)).astype(float) | |
| prev_high = high.shift(1) | |
| prev_close = close.shift(1) | |
| ma5 = df.get("ma5", close.rolling(5, min_periods=3).mean()).astype(float) | |
| ma10 = df.get("ma10", close.rolling(10, min_periods=5).mean()).astype(float) | |
| ma20 = df.get("ma20", close.rolling(20, min_periods=10).mean()).astype(float) | |
| ma5_slope = ma5.pct_change(3).fillna(0.0) | |
| ma10_slope = ma10.pct_change(3).fillna(0.0) | |
| ma20_slope = ma20.pct_change(3).fillna(0.0) | |
| triple_bull = ( | |
| (close > ma5) | |
| & (ma5 > ma10) | |
| & (ma10 > ma20) | |
| & (ma5_slope > 0) | |
| & (ma10_slope > 0) | |
| & (ma20_slope > 0) | |
| ) | |
| triple_bear = ( | |
| (close < ma5) | |
| & (ma5 < ma10) | |
| & (ma10 < ma20) | |
| & (ma5_slope < 0) | |
| & (ma10_slope < 0) | |
| & (ma20_slope < 0) | |
| ) | |
| out["oldwang_triple_bull"] = triple_bull.astype(float) | |
| out["oldwang_triple_bear"] = triple_bear.astype(float) | |
| out["oldwang_ma5_hold"] = (close >= ma5).astype(float) | |
| trust_5d = df.get("trust_net", pd.Series(0.0, index=df.index)).astype(float).rolling(5, min_periods=1).sum() | |
| foreign_5d = df.get("foreign_net", pd.Series(0.0, index=df.index)).astype(float).rolling(5, min_periods=1).sum() | |
| out["oldwang_trust_ma10_guard"] = ((trust_5d > 0) & (close >= ma10)).astype(float) | |
| out["oldwang_trust_ma10_broken"] = ((trust_5d > 0) & (close < ma10)).astype(float) | |
| out["oldwang_foreign_ma20_guard"] = ((foreign_5d > 0) & (close >= ma20)).astype(float) | |
| out["oldwang_foreign_ma20_broken"] = ((foreign_5d > 0) & (close < ma20)).astype(float) | |
| log_volume = np.log1p(volume.clip(lower=0.0)) | |
| volume_z = _zscore_prior(log_volume, 20, 10) | |
| volume_spike = volume_z >= 2.0 | |
| spike_high = _last_event_level(high, volume_spike) | |
| spike_low = _last_event_level(low, volume_spike) | |
| out["oldwang_volume_spike"] = volume_spike.astype(float) | |
| out["oldwang_volume_high_break"] = ((close > spike_high) & spike_high.notna()).astype(float) | |
| out["oldwang_volume_low_guard"] = ((close >= spike_low) & spike_low.notna()).astype(float) | |
| out["oldwang_volume_low_break"] = ((close < spike_low) & spike_low.notna()).astype(float) | |
| tr = _true_range(df) | |
| atr = df.get("atr", tr.rolling(14, min_periods=5).mean()).astype(float) | |
| atr_prior = atr.shift(1).replace(0, np.nan).bfill().fillna(close * 0.02) | |
| gap_up = open_ > prev_high | |
| gap_support = _last_event_level(prev_high, gap_up) | |
| recent_gap_up = _rolling_recent_flag(gap_up, 5) > 0 | |
| out["oldwang_gap_guard"] = (recent_gap_up & (low >= gap_support - 0.1 * atr_prior)).astype(float) | |
| out["oldwang_gap_filled"] = (recent_gap_up & (close < gap_support)).astype(float) | |
| out["oldwang_bull_score"] = ( | |
| out["oldwang_triple_bull"] | |
| + out["oldwang_ma5_hold"] | |
| + out["oldwang_trust_ma10_guard"] | |
| + out["oldwang_foreign_ma20_guard"] | |
| + out["oldwang_volume_high_break"] | |
| + out["oldwang_gap_guard"] | |
| ) | |
| out["oldwang_bear_score"] = ( | |
| out["oldwang_triple_bear"] | |
| + out["oldwang_trust_ma10_broken"] | |
| + out["oldwang_foreign_ma20_broken"] | |
| + out["oldwang_volume_low_break"] | |
| + out["oldwang_gap_filled"] | |
| ) | |
| return out[OLDWANG_FEATURES].replace([np.inf, -np.inf], np.nan).fillna(0.0) | |
| def _apply_veto(pred: np.ndarray, features: pd.DataFrame) -> np.ndarray: | |
| out = pred.copy() | |
| veto = features["oldwang_bear_score"].to_numpy() >= 1 | |
| out[(out == 1) & veto] = 0 | |
| return out | |
| def _apply_long_gate(pred: np.ndarray, features: pd.DataFrame) -> np.ndarray: | |
| out = pred.copy() | |
| allow_long = features["oldwang_bull_score"].to_numpy() >= 2 | |
| out[(out == 1) & ~allow_long] = 0 | |
| return out | |
| def walk_forward_oldwang(feat_df: pd.DataFrame, oldwang_df: pd.DataFrame, labels: np.ndarray) -> tuple[dict, dict[str, dict]]: | |
| base_avail = [ | |
| col for col in FEATURE_COLUMNS | |
| if col in feat_df.columns and col not in OLDWANG_FEATURES | |
| ] | |
| y_true_all: list[float] = [] | |
| y_pred_base_all: list[int] = [] | |
| y_pred_by_strategy: dict[str, list[int]] = {strategy: [] for strategy in STRATEGIES} | |
| n = len(feat_df) | |
| cutoff = MIN_TRAIN | |
| while cutoff + STEP + LABEL_HORIZON <= n: | |
| train_end = cutoff - LABEL_HORIZON | |
| if train_end < MIN_TRAIN: | |
| cutoff += STEP | |
| continue | |
| y_train_full = labels[:train_end] | |
| valid_train = np.where(~np.isnan(y_train_full))[0] | |
| if len(valid_train) < MIN_TRAIN or len(np.unique(y_train_full[valid_train])) < 2: | |
| cutoff += STEP | |
| continue | |
| test_end = min(cutoff + STEP, n - LABEL_HORIZON) | |
| y_test = labels[cutoff:test_end] | |
| valid_test_mask = ~np.isnan(y_test) | |
| if valid_test_mask.sum() == 0: | |
| cutoff += STEP | |
| continue | |
| test_idx = np.arange(cutoff, test_end)[valid_test_mask] | |
| X_train_base = feat_df.loc[valid_train, base_avail].fillna(0.0) | |
| X_test_base = feat_df.loc[test_idx, base_avail].fillna(0.0) | |
| X_train_oldwang = pd.concat( | |
| [X_train_base.reset_index(drop=True), oldwang_df.loc[valid_train, OLDWANG_FEATURES].reset_index(drop=True)], | |
| axis=1, | |
| ) | |
| X_test_oldwang = pd.concat( | |
| [X_test_base.reset_index(drop=True), oldwang_df.loc[test_idx, OLDWANG_FEATURES].reset_index(drop=True)], | |
| axis=1, | |
| ) | |
| y_train = y_train_full[valid_train].astype(int) | |
| base_model = RandomForestClassifier(**RF_PARAMS) | |
| oldwang_model = RandomForestClassifier(**RF_PARAMS) | |
| base_model.fit(X_train_base.values, y_train) | |
| oldwang_model.fit(X_train_oldwang.values, y_train) | |
| y_pred_base = base_model.predict(X_test_base.values) | |
| y_pred_oldwang = oldwang_model.predict(X_test_oldwang.values) | |
| test_oldwang = oldwang_df.loc[test_idx, OLDWANG_FEATURES] | |
| y_true_all.extend(y_test[valid_test_mask].tolist()) | |
| y_pred_base_all.extend(y_pred_base.tolist()) | |
| y_pred_by_strategy["oldwang_features"].extend(y_pred_oldwang.tolist()) | |
| y_pred_by_strategy["oldwang_veto"].extend(_apply_veto(y_pred_base, test_oldwang).tolist()) | |
| y_pred_by_strategy["oldwang_long_gate"].extend(_apply_long_gate(y_pred_base, test_oldwang).tolist()) | |
| y_pred_by_strategy["oldwang_features_veto"].extend(_apply_veto(y_pred_oldwang, test_oldwang).tolist()) | |
| cutoff += STEP | |
| if not y_true_all: | |
| return {}, {} | |
| y_true = np.array(y_true_all) | |
| baseline = compute_metrics(y_true, np.array(y_pred_base_all)) | |
| candidates = {strategy: compute_metrics(y_true, np.array(preds)) for strategy, preds in y_pred_by_strategy.items()} | |
| for row in candidates.values(): | |
| row["added_features"] = len(OLDWANG_FEATURES) | |
| return baseline, candidates | |
| def _mean(rows: list[dict], field: str) -> float: | |
| vals = [ | |
| row[field] | |
| for row in rows | |
| if isinstance(row.get(field), (int, float)) and not np.isnan(row.get(field, float("nan"))) | |
| ] | |
| return round(sum(vals) / len(vals), 1) if vals else float("nan") | |
| def _aggregate(rows: list[dict]) -> dict: | |
| return { | |
| "accuracy": _mean(rows, "accuracy"), | |
| "dir_accuracy": _mean(rows, "dir_accuracy"), | |
| "up_precision": _mean(rows, "up_precision"), | |
| "dn_precision": _mean(rows, "dn_precision"), | |
| "n_signals": _mean(rows, "n_signals"), | |
| "n_predictions": _mean(rows, "n_predictions"), | |
| } | |
| def _json_default(value): | |
| if isinstance(value, np.integer): | |
| return int(value) | |
| if isinstance(value, np.floating): | |
| return float(value) | |
| if isinstance(value, np.bool_): | |
| return bool(value) | |
| raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") | |
| def run(stocks: list[str], output_path: Path, *, with_institutional: bool = True) -> dict: | |
| per_stock: dict[str, dict] = {} | |
| agg_base: list[dict] = [] | |
| agg_by_strategy: dict[str, list[dict]] = {strategy: [] for strategy in STRATEGIES} | |
| hdr = f"{'Stock':>6} {'Model':>21} {'Acc%':>5} {'Dir%':>5} {'Up%':>6} {'Signals':>7} {'Delta':>6}" | |
| print(f"\n{hdr}\n{'-' * len(hdr)}") | |
| for stock_no in stocks: | |
| print(f" computing {stock_no}...", end="\r", flush=True) | |
| df = fetch_validation_df(stock_no, with_institutional=with_institutional) | |
| if df is None or df.empty: | |
| print(f"{stock_no:>6} no data") | |
| continue | |
| feat = _build_features(df) | |
| oldwang = build_oldwang_features(df) | |
| close = (df.set_index("date")["close"] if "date" in df.columns else df["close"]).values | |
| labels = build_triple_barrier_labels(close) | |
| baseline, candidates = walk_forward_oldwang(feat, oldwang, labels) | |
| if not baseline: | |
| print(f"{stock_no:>6} insufficient folds") | |
| continue | |
| agg_base.append(baseline) | |
| stock_row: dict[str, dict] = {"baseline": baseline} | |
| print( | |
| f"{stock_no:>6} {'baseline':>21} {baseline['accuracy']:>5.1f} " | |
| f"{baseline['dir_accuracy']:>5.1f} {baseline['up_precision']:>6.1f} " | |
| f"{baseline['n_signals']:>7} {'':>6}" | |
| ) | |
| for strategy in STRATEGIES: | |
| row = candidates[strategy] | |
| delta = round(row["accuracy"] - baseline["accuracy"], 1) | |
| signal_ratio = (row["n_signals"] / baseline["n_signals"]) if baseline["n_signals"] else 1.0 | |
| passed = delta >= PASS_PER_STOCK_ACCURACY_DELTA and signal_ratio >= MIN_SIGNAL_RATIO | |
| row = { | |
| **row, | |
| "accuracy_delta_pp": delta, | |
| "signal_ratio_vs_baseline": round(signal_ratio, 3), | |
| "passed": passed, | |
| } | |
| stock_row[strategy] = row | |
| agg_by_strategy[strategy].append(row) | |
| print( | |
| f"{'':>6} {strategy:>21} {row['accuracy']:>5.1f} " | |
| f"{row['dir_accuracy']:>5.1f} {row['up_precision']:>6.1f} " | |
| f"{row['n_signals']:>7} {delta:>+6.1f} {'PASS' if passed else 'FAIL'}" | |
| ) | |
| per_stock[stock_no] = stock_row | |
| aggregate = {"baseline": _aggregate(agg_base)} | |
| for strategy, rows in agg_by_strategy.items(): | |
| aggregate[strategy] = _aggregate(rows) | |
| aggregate[strategy]["accuracy_delta_pp"] = round(aggregate[strategy]["accuracy"] - aggregate["baseline"]["accuracy"], 1) | |
| base_signals = aggregate["baseline"].get("n_signals", 0) or 0 | |
| strategy_signals = aggregate[strategy].get("n_signals", 0) or 0 | |
| aggregate[strategy]["signal_ratio_vs_baseline"] = round(strategy_signals / base_signals, 3) if base_signals else 1.0 | |
| passing_strategies = [ | |
| strategy | |
| for strategy in STRATEGIES | |
| if per_stock | |
| and all(row[strategy]["passed"] for row in per_stock.values()) | |
| and aggregate[strategy]["signal_ratio_vs_baseline"] >= MIN_SIGNAL_RATIO | |
| ] | |
| best_strategy = max( | |
| STRATEGIES, | |
| key=lambda strategy: ( | |
| aggregate[strategy].get("accuracy_delta_pp", float("-inf")), | |
| aggregate[strategy].get("dir_accuracy", float("-inf")), | |
| ), | |
| default=None, | |
| ) | |
| print("\n=== AGGREGATE ===") | |
| print( | |
| f" {'baseline':>21}: acc={aggregate['baseline']['accuracy']}% " | |
| f"dir={aggregate['baseline']['dir_accuracy']}% up={aggregate['baseline']['up_precision']}% " | |
| f"signals={aggregate['baseline']['n_signals']}" | |
| ) | |
| for strategy in STRATEGIES: | |
| row = aggregate[strategy] | |
| print( | |
| f" {strategy:>21}: acc={row['accuracy']}% dir={row['dir_accuracy']}% " | |
| f"up={row['up_precision']}% signals={row['n_signals']} " | |
| f"delta={row['accuracy_delta_pp']:+.1f}pp signal_ratio={row['signal_ratio_vs_baseline']}" | |
| ) | |
| result = { | |
| "experiment": "C21_oldwang_rules", | |
| "description": "Dry run of Old Wang style MA, institutional guard, volume-bar, and gap-guard rules.", | |
| "stocks": stocks, | |
| "with_institutional": with_institutional, | |
| "pass_criterion": { | |
| "every_stock_accuracy_delta_pp_at_least": PASS_PER_STOCK_ACCURACY_DELTA, | |
| "min_signal_ratio_vs_baseline": MIN_SIGNAL_RATIO, | |
| }, | |
| "candidate_oldwang_features": OLDWANG_FEATURES, | |
| "strategies": STRATEGIES, | |
| "aggregate": aggregate, | |
| "best_strategy": best_strategy, | |
| "passing_strategies": passing_strategies, | |
| "per_stock": per_stock, | |
| "passed": bool(passing_strategies), | |
| "promotion_decision": "integrate" if passing_strategies else "do_not_integrate", | |
| "validation": { | |
| "mode": "walk_forward_with_embargo", | |
| "label_horizon": LABEL_HORIZON, | |
| "train_end": "cutoff - LABEL_HORIZON", | |
| "step": STEP, | |
| "min_train": MIN_TRAIN, | |
| "leakage_guard": "rules use current-close-known and prior rolling/event levels; training excludes the label horizon before each test slice.", | |
| }, | |
| } | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| output_path.write_text(json.dumps(result, indent=2, ensure_ascii=False, default=_json_default)) | |
| print(f"\nSaved -> {output_path}") | |
| return result | |
| def main() -> int: | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--stocks", default="", help="Comma-separated stock list") | |
| parser.add_argument("--extended", action="store_true", help="Use 12-stock extended validation") | |
| parser.add_argument("--no-institutional", action="store_true", help="Skip institutional flow fetch") | |
| parser.add_argument("--output", default="docs/c21_oldwang_rules_result.json") | |
| args = parser.parse_args() | |
| if args.stocks: | |
| stocks = [code.strip() for code in args.stocks.split(",") if code.strip()] | |
| else: | |
| stocks = EXTENDED_STOCKS if args.extended else DEFAULT_STOCKS | |
| result = run(stocks, ROOT / args.output, with_institutional=not args.no_institutional) | |
| return 0 if result.get("passed") else 1 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |