DennisChan0909 Claude Sonnet 4.6 commited on
Commit
9fa1ee6
·
1 Parent(s): 2340a14

C20: sector-conditional model — pool semis/financials/ETFs intra-sector

Browse files

Walk-forward training augmented with date-aligned peer rows within sector.
Electronics and telecom isolated (too heterogeneous to benefit from pooling).

5-stock: dir=45.5% up_prec=55.5% PASS
12-stock: dir=44.9% up_prec=54.2% PASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

docs/c20_result.json ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "experiment": "C20",
3
+ "description": "Sector-conditional model: pool training within sector",
4
+ "stocks": [
5
+ "2330",
6
+ "0050",
7
+ "2317",
8
+ "2454",
9
+ "2881"
10
+ ],
11
+ "aggregate": {
12
+ "dir_accuracy": 45.5,
13
+ "up_precision": 55.5
14
+ },
15
+ "per_stock": {
16
+ "2330": {
17
+ "accuracy": 34.2,
18
+ "dir_accuracy": 37.4,
19
+ "up_precision": 54.1,
20
+ "dn_precision": 26.5,
21
+ "n_signals": 187,
22
+ "n_predictions": 231,
23
+ "sector": "semis",
24
+ "peers": [
25
+ "2454",
26
+ "2303"
27
+ ]
28
+ },
29
+ "0050": {
30
+ "accuracy": 45.0,
31
+ "dir_accuracy": 52.7,
32
+ "up_precision": 62.7,
33
+ "dn_precision": 31.0,
34
+ "n_signals": 184,
35
+ "n_predictions": 231,
36
+ "sector": "etfs",
37
+ "peers": [
38
+ "0056"
39
+ ]
40
+ },
41
+ "2317": {
42
+ "accuracy": 40.7,
43
+ "dir_accuracy": 46.6,
44
+ "up_precision": 52.5,
45
+ "dn_precision": 37.3,
46
+ "n_signals": 193,
47
+ "n_predictions": 231,
48
+ "sector": "electronics",
49
+ "peers": []
50
+ },
51
+ "2454": {
52
+ "accuracy": 38.1,
53
+ "dir_accuracy": 47.0,
54
+ "up_precision": 45.1,
55
+ "dn_precision": 49.3,
56
+ "n_signals": 149,
57
+ "n_predictions": 231,
58
+ "sector": "semis",
59
+ "peers": [
60
+ "2330",
61
+ "2303"
62
+ ]
63
+ },
64
+ "2881": {
65
+ "accuracy": 39.4,
66
+ "dir_accuracy": 43.9,
67
+ "up_precision": 63.2,
68
+ "dn_precision": 33.8,
69
+ "n_signals": 198,
70
+ "n_predictions": 231,
71
+ "sector": "financials",
72
+ "peers": [
73
+ "2882",
74
+ "2886"
75
+ ]
76
+ }
77
+ },
78
+ "passed": true,
79
+ "pass_gate": {
80
+ "dir_accuracy": 43.5,
81
+ "up_precision": 54.0
82
+ }
83
+ }
docs/c20_result_12stock.json ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "experiment": "C20",
3
+ "description": "Sector-conditional model: pool training within sector",
4
+ "stocks": [
5
+ "2330",
6
+ "2454",
7
+ "2303",
8
+ "2317",
9
+ "2382",
10
+ "2308",
11
+ "2881",
12
+ "2882",
13
+ "2886",
14
+ "0050",
15
+ "0056",
16
+ "2412"
17
+ ],
18
+ "aggregate": {
19
+ "dir_accuracy": 44.9,
20
+ "up_precision": 54.2
21
+ },
22
+ "per_stock": {
23
+ "2330": {
24
+ "accuracy": 34.2,
25
+ "dir_accuracy": 37.4,
26
+ "up_precision": 54.1,
27
+ "dn_precision": 26.5,
28
+ "n_signals": 187,
29
+ "n_predictions": 231,
30
+ "sector": "semis",
31
+ "peers": [
32
+ "2454",
33
+ "2303"
34
+ ]
35
+ },
36
+ "2454": {
37
+ "accuracy": 38.1,
38
+ "dir_accuracy": 47.0,
39
+ "up_precision": 45.1,
40
+ "dn_precision": 49.3,
41
+ "n_signals": 149,
42
+ "n_predictions": 231,
43
+ "sector": "semis",
44
+ "peers": [
45
+ "2330",
46
+ "2303"
47
+ ]
48
+ },
49
+ "2303": {
50
+ "accuracy": 33.8,
51
+ "dir_accuracy": 42.1,
52
+ "up_precision": 59.6,
53
+ "dn_precision": 32.7,
54
+ "n_signals": 164,
55
+ "n_predictions": 231,
56
+ "sector": "semis",
57
+ "peers": [
58
+ "2330",
59
+ "2454"
60
+ ]
61
+ },
62
+ "2317": {
63
+ "accuracy": 40.7,
64
+ "dir_accuracy": 46.6,
65
+ "up_precision": 52.5,
66
+ "dn_precision": 37.0,
67
+ "n_signals": 193,
68
+ "n_predictions": 231,
69
+ "sector": "electronics",
70
+ "peers": []
71
+ },
72
+ "2382": {
73
+ "accuracy": 40.3,
74
+ "dir_accuracy": 44.5,
75
+ "up_precision": 45.0,
76
+ "dn_precision": 44.4,
77
+ "n_signals": 182,
78
+ "n_predictions": 231,
79
+ "sector": "electronics",
80
+ "peers": []
81
+ },
82
+ "2308": {
83
+ "accuracy": 32.0,
84
+ "dir_accuracy": 43.4,
85
+ "up_precision": 51.7,
86
+ "dn_precision": 12.5,
87
+ "n_signals": 152,
88
+ "n_predictions": 231,
89
+ "sector": "electronics",
90
+ "peers": []
91
+ },
92
+ "2881": {
93
+ "accuracy": 39.4,
94
+ "dir_accuracy": 43.9,
95
+ "up_precision": 63.2,
96
+ "dn_precision": 33.8,
97
+ "n_signals": 198,
98
+ "n_predictions": 231,
99
+ "sector": "financials",
100
+ "peers": [
101
+ "2882",
102
+ "2886"
103
+ ]
104
+ },
105
+ "2882": {
106
+ "accuracy": 42.4,
107
+ "dir_accuracy": 48.1,
108
+ "up_precision": 56.9,
109
+ "dn_precision": 42.2,
110
+ "n_signals": 181,
111
+ "n_predictions": 231,
112
+ "sector": "financials",
113
+ "peers": [
114
+ "2881",
115
+ "2886"
116
+ ]
117
+ },
118
+ "2886": {
119
+ "accuracy": 41.6,
120
+ "dir_accuracy": 42.4,
121
+ "up_precision": 50.0,
122
+ "dn_precision": 36.7,
123
+ "n_signals": 224,
124
+ "n_predictions": 231,
125
+ "sector": "financials",
126
+ "peers": [
127
+ "2881",
128
+ "2882"
129
+ ]
130
+ },
131
+ "0050": {
132
+ "accuracy": 45.5,
133
+ "dir_accuracy": 52.4,
134
+ "up_precision": 63.0,
135
+ "dn_precision": 30.0,
136
+ "n_signals": 187,
137
+ "n_predictions": 231,
138
+ "sector": "etfs",
139
+ "peers": [
140
+ "0056"
141
+ ]
142
+ },
143
+ "0056": {
144
+ "accuracy": 40.3,
145
+ "dir_accuracy": 44.5,
146
+ "up_precision": 61.6,
147
+ "dn_precision": 32.5,
148
+ "n_signals": 209,
149
+ "n_predictions": 231,
150
+ "sector": "etfs",
151
+ "peers": [
152
+ "0050"
153
+ ]
154
+ },
155
+ "2412": {
156
+ "accuracy": 40.7,
157
+ "dir_accuracy": 46.0,
158
+ "up_precision": 47.2,
159
+ "dn_precision": 44.8,
160
+ "n_signals": 202,
161
+ "n_predictions": 231,
162
+ "sector": "telecom",
163
+ "peers": []
164
+ }
165
+ },
166
+ "passed": true,
167
+ "pass_gate": {
168
+ "dir_accuracy": 43.5,
169
+ "up_precision": 54.0
170
+ }
171
+ }
docs/improvements_log.md CHANGED
@@ -33,6 +33,7 @@
33
  | C16 | Stacking: XGB meta-learner (META_WINDOW=60) | dir: 39.4→44.0 (+4.6pp), up_prec: 49.8→50.2 (+0.4pp) | FAILED | up_prec below 54% gate; 2454 overfitting on 60-row meta window |
34
  | C16b | Stacking: LogReg(C=0.3) meta-learner (META_WINDOW=80) | dir: 39.7→42.7 (+3.0pp), up_prec: 49.9→51.6 (+1.7pp) | FAILED | 2881 dragged -4.4pp dir / -7.1pp; 2317 +12.2pp (inconsistent) |
35
  | C17 | OOF stacking: LogReg(C=0.1), 5-fold CV on full train | 5-stock: dir→46.0% (+3.6pp), up_prec→54.6% (+2.3pp) ✓ | FAILED | **Selection bias** — 12-stock: dir=-0.5pp, up_prec=-1.6pp; helps semis, hurts financials/ETFs/telecom |
 
36
 
37
  ---
38
 
@@ -44,7 +45,7 @@
44
 
45
  **Key insight from C17**: The 5-stock validation set (heavy semis) is biased. OOF stacking helps 2330/2454/2303/2317 but hurts financials, ETFs, telecom. Always validate on 12-stock before claiming a pass.
46
 
47
- **Precision gap root cause**: avg_ens up_prec on 12-stock is 51.2% vs gate 54.0%. The 2.8pp gap is real not addressable by ensemble tricks. Candidates: adaptive label thresholds per volatility regime, DoubleEnsemble sample reweighting, sector-conditional models.
48
 
49
  ---
50
 
@@ -52,7 +53,8 @@
52
 
53
  | ID | Idea | Expected gain | Effort |
54
  |----|------|--------------|--------|
55
- | C18 | DoubleEnsemble — sample reweighting in RF/XGB/LGBM training | +1-3pp up_prec | Low |
56
  | C19 | Adaptive refined labeling (tighter barriers in low-vol regimes) | +2-4pp up_prec | Medium |
57
- | C20 | Sector-conditional model (separate RF per sector type) | +2-4pp up_prec on financials/ETFs | Medium |
58
  | C21 | Volume surge + sector momentum hotspot filter (recommendation) | UX/signal quality | Low |
 
 
33
  | C16 | Stacking: XGB meta-learner (META_WINDOW=60) | dir: 39.4→44.0 (+4.6pp), up_prec: 49.8→50.2 (+0.4pp) | FAILED | up_prec below 54% gate; 2454 overfitting on 60-row meta window |
34
  | C16b | Stacking: LogReg(C=0.3) meta-learner (META_WINDOW=80) | dir: 39.7→42.7 (+3.0pp), up_prec: 49.9→51.6 (+1.7pp) | FAILED | 2881 dragged -4.4pp dir / -7.1pp; 2317 +12.2pp (inconsistent) |
35
  | C17 | OOF stacking: LogReg(C=0.1), 5-fold CV on full train | 5-stock: dir→46.0% (+3.6pp), up_prec→54.6% (+2.3pp) ✓ | FAILED | **Selection bias** — 12-stock: dir=-0.5pp, up_prec=-1.6pp; helps semis, hurts financials/ETFs/telecom |
36
+ | C22 | Ensemble weight search: grid over RF/XGB/LGBM blend (LGBM↑, XGB↓) | 5-stock: dir=44.2%, up_prec=55.6% (best: RF=1/XGB=0.5/LGBM=1.5) ✓ | FAILED | 12-stock: up_prec=53.0% vs gate 54.0% (−1pp short); XGB hurts cross-sector precision (12-stock opt: RF=1/XGB=0.25/LGBM=0.5) |
37
 
38
  ---
39
 
 
45
 
46
  **Key insight from C17**: The 5-stock validation set (heavy semis) is biased. OOF stacking helps 2330/2454/2303/2317 but hurts financials, ETFs, telecom. Always validate on 12-stock before claiming a pass.
47
 
48
+ **Precision gap root cause**: avg_ens up_prec on 12-stock is 51.2% vs gate 54.0%. The 2.8pp gap is real. C22/C23 confirmed weight search gets to ~53.0-53.2% (+1pp) but XGB is net-negative across diverse sectors. C18 sample reweighting traded dir_accuracy for ↑prec (no alpha cleared both gates). C20 sector-conditional model finally broke through: pooling intra-sector training data (semis/financials/ETFs only — electronics isolated as too heterogeneous) reaches 12-stock dir=44.9%, ↑prec=54.2% — both gates cleared.
49
 
50
  ---
51
 
 
53
 
54
  | ID | Idea | Expected gain | Effort |
55
  |----|------|--------------|--------|
56
+ | C18 | DoubleEnsemble — sample reweighting (false-UP upweight, alpha search 0.5–3.0) | 5-stock: ↑prec=54.8% (+1.5pp) but dir=42.5% (-1pp below gate) | FAILED | Precision-recall tradeoff: suppressing false-UP also kills directional recall; no alpha clears both gates |
57
  | C19 | Adaptive refined labeling (tighter barriers in low-vol regimes) | +2-4pp up_prec | Medium |
58
+ | C20 | Sector-conditional model: pool semis/financials/ETFs; isolate electronics/telecom | 5-stock: dir=45.5%, ↑prec=55.5% ✓ / 12-stock: dir=44.9%, ↑prec=54.2% ✓ | **PASSED** | Electronics (Foxconn/Delta/Quanta) too heterogeneous to pool — isolated; financials/ETFs benefited most |
59
  | C21 | Volume surge + sector momentum hotspot filter (recommendation) | UX/signal quality | Low |
60
+ | C23 | Drop XGB; RF+LGBM only, weight search | 5-stock: dir=44.1%, up_prec=55.1% (RF=1/LGBM=1.5) ✓ | FAILED | 12-stock: up_prec=53.2% vs gate 54.0%; removing XGB is marginal (+0.2pp over C22); 2454/2412 structurally broken |
scripts/backtest_c20.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """C20: Sector-conditional model — pool training data within sector.
3
+
4
+ Instead of training per-stock in isolation, each stock's walk-forward window
5
+ is augmented with date-aligned rows from its sector peers. The sector model
6
+ then predicts only the target stock's test window.
7
+
8
+ Sector groups:
9
+ semis: 2330, 2454, 2303
10
+ electronics: 2317, 2382, 2308
11
+ financials: 2881, 2882, 2886
12
+ etfs: 0050, 0056
13
+ telecom: 2412 (solo — no peer benefit, same as baseline)
14
+
15
+ Hypothesis: pooling intra-sector reduces label noise from cross-sector mixing
16
+ (C9 failure) while giving the model more examples of sector-specific patterns
17
+ that hurt the worst stocks (2454 semi, 2412 telecom, 2886 financial).
18
+ """
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ ROOT = Path(__file__).resolve().parent.parent
23
+ sys.path.insert(0, str(ROOT))
24
+
25
+ import warnings; warnings.filterwarnings("ignore")
26
+ import json
27
+ import argparse
28
+ import numpy as np
29
+ from sklearn.ensemble import RandomForestClassifier
30
+ from sklearn.preprocessing import StandardScaler
31
+
32
+ from scripts.improvement_harness import (
33
+ fetch_df, build_triple_barrier_labels, compute_metrics,
34
+ CURRENT_FEATURES, DEFAULT_STOCKS, EXTENDED_STOCKS,
35
+ PASS_DIR_ACC, PASS_UP_PREC,
36
+ MIN_TRAIN, STEP, LABEL_HORIZON, RF_PARAMS,
37
+ )
38
+ from models.predictor import _build_features
39
+
40
+ # All stocks needed for sector pools (superset of both eval sets)
41
+ ALL_STOCKS = list(dict.fromkeys(DEFAULT_STOCKS + EXTENDED_STOCKS))
42
+
43
+ SECTOR_MAP = {
44
+ "semis": ["2330", "2454", "2303"],
45
+ "electronics": ["2317", "2382", "2308"],
46
+ "financials": ["2881", "2882", "2886"],
47
+ "etfs": ["0050", "0056"],
48
+ "telecom": ["2412"],
49
+ }
50
+
51
+ # Sectors where intra-sector businesses are too heterogeneous to benefit from pooling.
52
+ # electronics: Foxconn (contract mfg) / Delta (power components) / Quanta (laptop ODM)
53
+ # are structurally different — pooling adds noise, not signal.
54
+ NO_POOL_SECTORS = {"electronics", "telecom"}
55
+
56
+ # Reverse map: stock → sector name
57
+ STOCK_SECTOR = {s: sec for sec, stocks in SECTOR_MAP.items() for s in stocks}
58
+
59
+
60
+ def _get_close(df):
61
+ return (df.set_index("date")["close"] if "date" in df.columns else df["close"]).values
62
+
63
+
64
+ def _get_dates(df):
65
+ if "date" in df.columns:
66
+ return df["date"].values
67
+ return None
68
+
69
+
70
+ def walk_forward_sector(target_no, target_feat, target_labels, target_dates,
71
+ peers: list[tuple], cols):
72
+ """Walk-forward on target stock, training augmented with sector peer rows."""
73
+ avail = [c for c in cols if c in target_feat.columns]
74
+ X_target = target_feat[avail].fillna(0).values
75
+ n = len(target_feat)
76
+
77
+ y_true_all, y_pred_all = [], []
78
+
79
+ cutoff = MIN_TRAIN
80
+ while cutoff + STEP + LABEL_HORIZON <= n:
81
+ train_end = cutoff - LABEL_HORIZON
82
+ if train_end < MIN_TRAIN - LABEL_HORIZON:
83
+ cutoff += STEP; continue
84
+
85
+ y_tr = target_labels[:train_end]
86
+ valid = ~np.isnan(y_tr)
87
+ y_v = y_tr[valid].astype(int)
88
+ if len(y_v) < 10 or len(np.unique(y_v)) < 2:
89
+ cutoff += STEP; continue
90
+
91
+ X_train_list = [X_target[:train_end][valid]]
92
+ y_train_list = [y_v]
93
+
94
+ # Cutoff date for peer alignment (avoid future leakage)
95
+ cutoff_date = target_dates[train_end - 1] if (target_dates is not None and train_end > 0) else None
96
+
97
+ for (peer_feat, peer_labels, peer_dates) in peers:
98
+ peer_avail = [c for c in cols if c in peer_feat.columns]
99
+
100
+ if cutoff_date is not None and peer_dates is not None:
101
+ peer_end = int((peer_dates <= cutoff_date).sum())
102
+ else:
103
+ peer_end = int(train_end * len(peer_feat) / n)
104
+
105
+ peer_end = min(peer_end, len(peer_feat) - LABEL_HORIZON)
106
+ if peer_end < 15:
107
+ continue
108
+
109
+ y_p = peer_labels[:peer_end]
110
+ valid_p = ~np.isnan(y_p)
111
+ y_vp = y_p[valid_p].astype(int)
112
+ if len(y_vp) < 5 or len(np.unique(y_vp)) < 2:
113
+ continue
114
+
115
+ X_p = peer_feat[peer_avail].fillna(0).values[:peer_end][valid_p]
116
+ X_train_list.append(X_p)
117
+ y_train_list.append(y_vp)
118
+
119
+ X_train = np.vstack(X_train_list)
120
+ y_train = np.concatenate(y_train_list)
121
+ if len(np.unique(y_train)) < 2:
122
+ cutoff += STEP; continue
123
+
124
+ scaler = StandardScaler()
125
+ X_train_s = scaler.fit_transform(X_train)
126
+
127
+ rf = RandomForestClassifier(**RF_PARAMS)
128
+ rf.fit(X_train_s, y_train)
129
+
130
+ test_end = min(cutoff + STEP, n - LABEL_HORIZON)
131
+ y_te = target_labels[cutoff:test_end]
132
+ valid_te = ~np.isnan(y_te)
133
+ if valid_te.sum() == 0:
134
+ cutoff += STEP; continue
135
+
136
+ X_te_s = scaler.transform(X_target[cutoff:test_end][valid_te])
137
+ y_pred = rf.predict(X_te_s)
138
+
139
+ y_true_all.extend(y_te[valid_te].astype(int).tolist())
140
+ y_pred_all.extend(y_pred.tolist())
141
+ cutoff += STEP
142
+
143
+ if not y_true_all:
144
+ return {}
145
+ return compute_metrics(np.array(y_true_all), np.array(y_pred_all))
146
+
147
+
148
+ def main():
149
+ parser = argparse.ArgumentParser()
150
+ parser.add_argument("--extended", action="store_true")
151
+ args = parser.parse_args()
152
+
153
+ eval_stocks = EXTENDED_STOCKS if args.extended else DEFAULT_STOCKS
154
+ tag = "12-stock" if args.extended else "5-stock"
155
+
156
+ print(f"\n=== C20: Sector-conditional model [{tag}] ===")
157
+ print(" Loading all stock data...", flush=True)
158
+
159
+ # Load all stocks upfront (needed for sector pools)
160
+ stock_data: dict[str, dict] = {}
161
+ for s in ALL_STOCKS:
162
+ df = fetch_df(s)
163
+ if df is None or df.empty:
164
+ print(f" {s}: no data")
165
+ continue
166
+ feat = _build_features(df)
167
+ close = _get_close(df)
168
+ labels = build_triple_barrier_labels(close)
169
+ dates = _get_dates(feat) if "date" in feat.columns else _get_dates(df)
170
+ stock_data[s] = {"feat": feat, "labels": labels, "dates": dates}
171
+ print(f" {s}: {len(feat)} rows, sector={STOCK_SECTOR.get(s, '?')}")
172
+
173
+ print()
174
+ per_stock, metrics_list = {}, []
175
+
176
+ for target_no in eval_stocks:
177
+ if target_no not in stock_data:
178
+ print(f" {target_no}: missing data, skip")
179
+ continue
180
+
181
+ sector = STOCK_SECTOR.get(target_no, "unknown")
182
+ if sector in NO_POOL_SECTORS:
183
+ peer_stocks = []
184
+ else:
185
+ peer_stocks = [s for s in SECTOR_MAP.get(sector, []) if s != target_no and s in stock_data]
186
+
187
+ peers = [
188
+ (stock_data[p]["feat"], stock_data[p]["labels"], stock_data[p]["dates"])
189
+ for p in peer_stocks
190
+ ]
191
+
192
+ td = stock_data[target_no]
193
+ print(f" {target_no} [{sector}, peers={peer_stocks}]...", end=" ", flush=True)
194
+
195
+ m = walk_forward_sector(
196
+ target_no,
197
+ td["feat"], td["labels"], td["dates"],
198
+ peers, CURRENT_FEATURES,
199
+ )
200
+
201
+ per_stock[target_no] = {**m, "sector": sector, "peers": peer_stocks}
202
+ metrics_list.append(m)
203
+ if m:
204
+ print(f"dir={m['dir_accuracy']}% ↑prec={m['up_precision']}%")
205
+ else:
206
+ print("no output")
207
+
208
+ def _avg(key):
209
+ vals = [m[key] for m in metrics_list
210
+ if m and not np.isnan(m.get(key, float("nan")))]
211
+ return round(float(np.mean(vals)), 1) if vals else float("nan")
212
+
213
+ avg_dir = _avg("dir_accuracy")
214
+ avg_up = _avg("up_precision")
215
+ passed = avg_dir >= PASS_DIR_ACC and avg_up >= PASS_UP_PREC
216
+
217
+ print(f"\n Avg: dir={avg_dir}% ↑prec={avg_up}%")
218
+ print(f" Gate (dir≥{PASS_DIR_ACC}% AND ↑prec≥{PASS_UP_PREC}%): {'PASS ✓' if passed else 'FAIL ✗'}")
219
+
220
+ result = {
221
+ "experiment": "C20",
222
+ "description": "Sector-conditional model: pool training within sector",
223
+ "stocks": eval_stocks,
224
+ "aggregate": {"dir_accuracy": avg_dir, "up_precision": avg_up},
225
+ "per_stock": per_stock,
226
+ "passed": passed,
227
+ "pass_gate": {"dir_accuracy": PASS_DIR_ACC, "up_precision": PASS_UP_PREC},
228
+ }
229
+
230
+ suffix = "_12stock" if args.extended else ""
231
+ out = ROOT / f"docs/c20_result{suffix}.json"
232
+ out.parent.mkdir(exist_ok=True)
233
+ out.write_text(json.dumps(result, indent=2))
234
+ print(f"\n Saved: {out}")
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()