georgeiac00 commited on
Commit
421e17f
Β·
verified Β·
1 Parent(s): 9b92f8b

Upload afres_v2.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. afres_v2.py +838 -0
afres_v2.py ADDED
@@ -0,0 +1,838 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AFRES v2: Agentic Factor Revision and Evaluation System
3
+
4
+ A faithful adaptation of APRES's rubric-discovery pipeline to quantitative
5
+ factor generation. Key design choices (per user request):
6
+
7
+ 1. LLM agent generates factor expressions from a discovered rubric.
8
+ 2. A regression model (factor-value β†’ future-return) provides the fitness
9
+ signal. Fitness = –MAE (or IC) on a held-out test set.
10
+ 3. NO LLM-as-judge. Factor quality is determined entirely by the data.
11
+ 4. NO QD / MAP-Elites. The search budget is spent on rubric discovery.
12
+ 5. The rubric-discovery loop mirrors APRES Section 3.1:
13
+ Propose β†’ Generate Factors β†’ Evaluate via Regression β†’ Select & Refine
14
+ with MultiAIDE-style tree search (branching, debug-and-retry).
15
+
16
+ Architecture
17
+ ────────────
18
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
19
+ β”‚ Rubric │────→│ LLM Factor │────→│ Regression β”‚
20
+ β”‚ Proposer β”‚ β”‚ Generator β”‚ β”‚ Evaluator β”‚
21
+ β”‚ (LLM) β”‚ β”‚ (prompt β†’ expr)β”‚ β”‚ (sklearn) β”‚
22
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
23
+ ↑ β”‚
24
+ β”‚ Select & Refine (MultiAIDE tree) β”‚
25
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
26
+
27
+ References
28
+ β€’ APRES – arXiv:2603.03142 (Sec. 3.1 rubric search)
29
+ β€’ MultiAIDE – Zhao et al. 2025 (tree-search scaffold)
30
+ """
31
+
32
+ import json
33
+ import re
34
+ import time
35
+ import copy
36
+ import warnings
37
+ from dataclasses import dataclass, field
38
+ from typing import List, Dict, Optional, Tuple, Callable
39
+ from enum import Enum
40
+ from collections import defaultdict
41
+
42
+ import numpy as np
43
+ import pandas as pd
44
+ from sklearn.linear_model import Ridge
45
+ from sklearn.ensemble import RandomForestRegressor
46
+ from sklearn.metrics import mean_absolute_error, r2_score
47
+
48
+ warnings.filterwarnings("ignore")
49
+
50
+ # ═════════════════════════════════════════════════════════════════════════════
51
+ # 1. DATA STRUCTURES
52
+ # ═════════════════════════════════════════════════════════════════════════════
53
+
54
+ class SignalType(Enum):
55
+ PRICE_BASED = "price_based"
56
+ VOLUME_BASED = "volume_based"
57
+ FUNDAMENTAL = "fundamental"
58
+ TECHNICAL = "technical"
59
+ CROSS_SECTIONAL = "cross_sectional"
60
+ TIME_SERIES = "time_series"
61
+
62
+ @dataclass
63
+ class RubricItem:
64
+ """One actionable design principle for factor generation."""
65
+ id: str
66
+ description: str
67
+ # Actionable constraints passed to the generator
68
+ feature_focus: List[str] = field(default_factory=list)
69
+ preferred_ops: List[str] = field(default_factory=list)
70
+ time_horizon_hint: str = "any" # short / medium / long / any
71
+ complexity_hint: str = "any" # low / medium / high / any
72
+ weight: float = 1.0
73
+
74
+ def to_dict(self) -> Dict:
75
+ return {
76
+ "id": self.id, "description": self.description,
77
+ "feature_focus": self.feature_focus,
78
+ "preferred_ops": self.preferred_ops,
79
+ "time_horizon_hint": self.time_horizon_hint,
80
+ "complexity_hint": self.complexity_hint,
81
+ "weight": self.weight,
82
+ }
83
+
84
+ @dataclass
85
+ class FactorRubric:
86
+ items: List[RubricItem]
87
+ def to_dict(self):
88
+ return {"items": [i.to_dict() for i in self.items]}
89
+
90
+ @dataclass
91
+ class Factor:
92
+ id: str
93
+ expression: str
94
+ rubric_id: str # which rubric variant produced this factor
95
+ ic: float = 0.0
96
+ mae: float = 0.0 # regression MAE (lower = better)
97
+ r2: float = 0.0 # regression RΒ²
98
+ sharpe: float = 0.0
99
+ returns: float = 0.0
100
+ generation: int = 0
101
+ valid: bool = True # False if expression failed to evaluate
102
+
103
+ def to_dict(self):
104
+ return {
105
+ "id": self.id, "expression": self.expression,
106
+ "rubric_id": self.rubric_id, "ic": self.ic,
107
+ "mae": self.mae, "r2": self.r2, "sharpe": self.sharpe,
108
+ "returns": self.returns, "generation": self.generation,
109
+ "valid": self.valid,
110
+ }
111
+
112
+ # ═════════════════════════════════════════════════════════════════════════════
113
+ # 2. MARKET DATA (panel: days Γ— stocks, pre-computed features)
114
+ # ═════════════════════════════════════════════════════════════════════════════
115
+
116
+ class MarketData:
117
+ """
118
+ Synthetic market panel with baked-in predictive structure so that
119
+ well-designed factors can genuinely outperform random ones.
120
+ """
121
+
122
+ def __init__(self, n_stocks: int = 80, n_days: int = 600, seed: int = 42):
123
+ rng = np.random.RandomState(seed)
124
+ self.n_stocks = n_stocks
125
+ self.n_days = n_days
126
+ self.dates = pd.date_range("2020-01-01", periods=n_days, freq="B")
127
+ self.symbols = [f"S{i:03d}" for i in range(n_stocks)]
128
+
129
+ # ── raw price / volume panels ──
130
+ self.close = np.zeros((n_days, n_stocks))
131
+ self.open_ = np.zeros((n_days, n_stocks))
132
+ self.high = np.zeros((n_days, n_stocks))
133
+ self.low = np.zeros((n_days, n_stocks))
134
+ self.volume = np.zeros((n_days, n_stocks))
135
+
136
+ for i in range(n_stocks):
137
+ # base returns
138
+ ret = rng.normal(0.0002, 0.018, n_days)
139
+
140
+ # ── predictable structure (weak but real) ──
141
+ # momentum: past 5-day ret β†’ next day (+)
142
+ mom = np.zeros(n_days)
143
+ mom[5:] = 0.25 * ret[:-5]
144
+ # mean-reversion: deviation from 10-day mean β†’ next day (–)
145
+ rev = np.zeros(n_days)
146
+ for t in range(10, n_days):
147
+ rev[t] = -0.15 * (ret[t - 1] - ret[t - 10:t].mean())
148
+ # volume-return interaction
149
+ vol_signal = np.zeros(n_days)
150
+ vol_signal[5:] = 0.10 * np.abs(ret[:-5]) * rng.lognormal(0, 0.3, n_days - 5)
151
+
152
+ ret[10:] += mom[10:] + rev[10:] + vol_signal[10:]
153
+
154
+ prices = 100 * np.exp(np.cumsum(ret))
155
+ self.close[:, i] = prices
156
+ self.open_[:, i] = prices * (1 + rng.normal(0, 0.001, n_days))
157
+ self.high[:, i] = prices * (1 + np.abs(rng.normal(0, 0.008, n_days)))
158
+ self.low[:, i] = prices * (1 - np.abs(rng.normal(0, 0.008, n_days)))
159
+ self.volume[:, i] = rng.lognormal(15, 0.3, n_days)
160
+
161
+ # ── pre-compute feature panels ──
162
+ self.ret_1d = np.diff(self.close, axis=0, prepend=self.close[:1]) / (self.close + 1e-10)
163
+ self.ret_5d = np.zeros_like(self.close)
164
+ self.ret_5d[5:] = (self.close[5:] - self.close[:-5]) / (self.close[:-5] + 1e-10)
165
+ self.ret_20d = np.zeros_like(self.close)
166
+ self.ret_20d[20:] = (self.close[20:] - self.close[:-20]) / (self.close[:-20] + 1e-10)
167
+
168
+ df_close = pd.DataFrame(self.close)
169
+ self.sma_5 = df_close.rolling(5, min_periods=1).mean().values
170
+ self.sma_10 = df_close.rolling(10, min_periods=1).mean().values
171
+ self.sma_20 = df_close.rolling(20, min_periods=1).mean().values
172
+ self.vol_20d = pd.DataFrame(self.ret_1d).rolling(20, min_periods=1).std().values
173
+
174
+ df_volume = pd.DataFrame(self.volume)
175
+ self.vol_sma_20 = df_volume.rolling(20, min_periods=1).mean().values
176
+ self.high_20d = pd.DataFrame(self.high).rolling(20, min_periods=1).max().values
177
+ self.low_20d = pd.DataFrame(self.low).rolling(20, min_periods=1).min().values
178
+ self.vwap = self.close * (1 + rng.normal(0, 0.0003, (n_days, n_stocks)))
179
+
180
+ # ── target: next-day return ──
181
+ self.future_ret = np.zeros_like(self.close)
182
+ self.future_ret[:-1] = np.diff(self.close, axis=0) / (self.close[:-1] + 1e-10)
183
+
184
+ # ── train / test split ──
185
+ self.train_idx = np.arange(30, 450) # skip first 30 for feature warmup
186
+ self.test_idx = np.arange(450, min(580, n_days - 1))
187
+
188
+ def eval_expr(self, expr: str) -> Optional[np.ndarray]:
189
+ """Evaluate a factor expression β†’ (days Γ— stocks) array."""
190
+ ns = {
191
+ 'close': self.close, 'open': self.open_, 'high': self.high,
192
+ 'low': self.low, 'volume': self.volume, 'vwap': self.vwap,
193
+ 'returns_1d': self.ret_1d, 'returns_5d': self.ret_5d,
194
+ 'returns_20d': self.ret_20d, 'sma_5': self.sma_5,
195
+ 'sma_10': self.sma_10, 'sma_20': self.sma_20,
196
+ 'volatility_20d': self.vol_20d, 'volume_sma_20': self.vol_sma_20,
197
+ 'high_20d': self.high_20d, 'low_20d': self.low_20d,
198
+ 'np': np, 'abs': np.abs, 'log': np.log, 'sqrt': np.sqrt,
199
+ 'sign': np.sign,
200
+ 'rank': lambda x: self._rank(x),
201
+ 'ts_mean': lambda x, w: self._ts(x, w, 'mean'),
202
+ 'ts_std': lambda x, w: self._ts(x, w, 'std'),
203
+ 'ts_max': lambda x, w: self._ts(x, w, 'max'),
204
+ 'ts_min': lambda x, w: self._ts(x, w, 'min'),
205
+ 'ts_zscore': lambda x, w: (x - self._ts(x, w, 'mean')) /
206
+ (self._ts(x, w, 'std') + 1e-10),
207
+ 'ts_delta': lambda x, w: self._delta(x, w),
208
+ 'ts_corr': lambda x, y, w: self._corr(x, y, w),
209
+ 'ts_cov': lambda x, y, w: self._cov(x, y, w),
210
+ 'ts_rank': lambda x, w: self._tsrank(x, w),
211
+ }
212
+ try:
213
+ result = eval(expr, {"__builtins__": {}}, ns)
214
+ if isinstance(result, np.ndarray) and result.shape == (self.n_days, self.n_stocks):
215
+ return result
216
+ return None
217
+ except Exception:
218
+ return None
219
+
220
+ # ---- helpers ----
221
+ def _rank(self, x):
222
+ r = np.zeros_like(x)
223
+ for t in range(x.shape[0]):
224
+ valid = np.isfinite(x[t])
225
+ if valid.sum() > 0:
226
+ r[t, valid] = pd.Series(x[t, valid]).rank(pct=True).values
227
+ return r
228
+
229
+ def _ts(self, x, w, method):
230
+ df = pd.DataFrame(x)
231
+ if method == 'mean': return df.rolling(w, min_periods=1).mean().values
232
+ if method == 'std': return df.rolling(w, min_periods=1).std().values
233
+ if method == 'max': return df.rolling(w, min_periods=1).max().values
234
+ if method == 'min': return df.rolling(w, min_periods=1).min().values
235
+ return x
236
+
237
+ def _delta(self, x, w):
238
+ out = np.zeros_like(x)
239
+ out[w:] = x[w:] - x[:-w]
240
+ return out
241
+
242
+ def _corr(self, x, y, w):
243
+ r = np.zeros_like(x)
244
+ for t in range(x.shape[0]):
245
+ a = x[max(0, t - w + 1):t + 1].flatten()
246
+ b = y[max(0, t - w + 1):t + 1].flatten()
247
+ if len(a) > 1 and np.std(a) > 0 and np.std(b) > 0:
248
+ r[t] = np.corrcoef(a, b)[0, 1]
249
+ return r
250
+
251
+ def _cov(self, x, y, w):
252
+ c = np.zeros_like(x)
253
+ for t in range(x.shape[0]):
254
+ a = x[max(0, t - w + 1):t + 1].flatten()
255
+ b = y[max(0, t - w + 1):t + 1].flatten()
256
+ c[t] = np.cov(a, b)[0, 1] if len(a) > 1 else 0.0
257
+ return c
258
+
259
+ def _tsrank(self, x, w):
260
+ r = np.zeros_like(x)
261
+ for t in range(x.shape[0]):
262
+ vals = x[max(0, t - w + 1):t + 1].flatten()
263
+ if len(vals) >= w:
264
+ r[t] = pd.Series(vals).rank(pct=True).values[-1]
265
+ else:
266
+ r[t] = np.nan
267
+ return r
268
+
269
+ # ═════════════════════════════════════════════════════════════════════════════
270
+ # 3. REGRESSION EVALUATOR (factor value β†’ future return)
271
+ # ═════════════════════════════════════════════════════════════════════════════
272
+
273
+ class RegressionEvaluator:
274
+ """
275
+ Trains a regression model: factor_value β†’ next-day return.
276
+ Fitness is reported as:
277
+ β€’ MAE (Mean Absolute Error) – primary metric, lower = better
278
+ β€’ IC (Spearman rank correlation) – cross-sectional predictive power
279
+ β€’ RΒ² (coefficient of determination)
280
+ β€’ Sharpe & returns from a simple long-top-quintile strategy
281
+ """
282
+
283
+ def __init__(self, data: MarketData, model_type: str = "ridge"):
284
+ self.data = data
285
+ self.model_type = model_type
286
+
287
+ def evaluate(self, factor: Factor) -> Dict[str, float]:
288
+ vals = self.data.eval_expr(factor.expression)
289
+ if vals is None:
290
+ factor.valid = False
291
+ return {"mae": 1e6, "ic": 0.0, "r2": -1.0, "sharpe": 0.0, "returns": 0.0}
292
+
293
+ # ── flatten panel to (sample, feature) for regression ──
294
+ X_train, y_train = self._flatten(vals, self.data.train_idx)
295
+ X_test, y_test = self._flatten(vals, self.data.test_idx)
296
+
297
+ if len(X_train) < 100 or len(X_test) < 50:
298
+ factor.valid = False
299
+ return {"mae": 1e6, "ic": 0.0, "r2": -1.0, "sharpe": 0.0, "returns": 0.0}
300
+
301
+ # ── train regression ──
302
+ model = Ridge(alpha=1.0) if self.model_type == "ridge" else \
303
+ RandomForestRegressor(n_estimators=50, max_depth=6, random_state=42, n_jobs=-1)
304
+ model.fit(X_train, y_train)
305
+ pred_test = model.predict(X_test)
306
+
307
+ mae = mean_absolute_error(y_test, pred_test)
308
+ r2 = r2_score(y_test, pred_test)
309
+
310
+ # ── IC (cross-sectional rank correlation per day) ──
311
+ ics = []
312
+ for t in self.data.test_idx:
313
+ f_t = vals[t]
314
+ r_t = self.data.future_ret[t]
315
+ valid = np.isfinite(f_t) & np.isfinite(r_t)
316
+ if valid.sum() >= 10:
317
+ ic = np.corrcoef(pd.Series(f_t[valid]).rank().values,
318
+ pd.Series(r_t[valid]).rank().values)[0, 1]
319
+ if np.isfinite(ic):
320
+ ics.append(ic)
321
+ ic = float(np.mean(ics)) if ics else 0.0
322
+
323
+ # ── simple portfolio Sharpe ──
324
+ port_rets = []
325
+ for t in self.data.test_idx:
326
+ f_t = vals[t]
327
+ r_t = self.data.future_ret[t]
328
+ valid = np.isfinite(f_t) & np.isfinite(r_t)
329
+ if valid.sum() >= 10:
330
+ q80 = np.percentile(f_t[valid], 80)
331
+ mask = (f_t >= q80) & valid
332
+ if mask.sum() > 0:
333
+ port_rets.append(float(np.mean(r_t[mask])))
334
+
335
+ if len(port_rets) > 2:
336
+ rets_arr = np.array(port_rets)
337
+ ann_ret = float(np.mean(rets_arr) * 252)
338
+ sharpe = float((np.mean(rets_arr) / (np.std(rets_arr) + 1e-10)) * np.sqrt(252))
339
+ else:
340
+ ann_ret = 0.0
341
+ sharpe = 0.0
342
+
343
+ factor.mae = mae
344
+ factor.ic = ic
345
+ factor.r2 = r2
346
+ factor.sharpe = sharpe
347
+ factor.returns = ann_ret
348
+ factor.valid = True
349
+ return {"mae": mae, "ic": ic, "r2": r2, "sharpe": sharpe, "returns": ann_ret}
350
+
351
+ def _flatten(self, vals: np.ndarray, idx: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
352
+ """Flatten panel slices to (n_samples, 1) feature matrix + target vector."""
353
+ X = vals[idx].flatten().reshape(-1, 1)
354
+ y = self.data.future_ret[idx].flatten()
355
+ mask = np.isfinite(X[:, 0]) & np.isfinite(y)
356
+ return X[mask], y[mask]
357
+
358
+ # ═════════════════════════════════════════════════════════════════════════════
359
+ # 4. LLM FACTOR GENERATOR (interface + simulated implementation)
360
+ # ═════════════════════════════════════════════════════════════════════════════
361
+
362
+ class LLMFactorGenerator:
363
+ """
364
+ Generates factor expressions from a rubric.
365
+ In production this would call an LLM API (GPT-4, Claude, etc.).
366
+ The prototype uses a template-based simulator that respects the
367
+ actionable constraints in each RubricItem.
368
+ """
369
+
370
+ # operator library
371
+ OPS = ["ts_mean", "ts_std", "ts_max", "ts_min", "ts_zscore",
372
+ "ts_delta", "ts_corr", "ts_cov", "ts_rank", "rank",
373
+ "abs", "sign", "log", "sqrt"]
374
+
375
+ FEATURES = ["close", "open", "high", "low", "volume", "vwap",
376
+ "returns_1d", "returns_5d", "returns_20d",
377
+ "sma_5", "sma_10", "sma_20",
378
+ "volatility_20d", "volume_sma_20",
379
+ "high_20d", "low_20d"]
380
+
381
+ WINDOWS = [3, 5, 10, 20]
382
+
383
+ def __init__(self, seed: int = 99):
384
+ self.rng = np.random.RandomState(seed)
385
+ self._counter = 0
386
+
387
+ def generate(self, rubric: FactorRubric, n: int = 5) -> List[Factor]:
388
+ """Generate n factor expressions constrained by the rubric."""
389
+ # Aggregate constraints from rubric items
390
+ features = self._extract_features(rubric)
391
+ ops = self._extract_ops(rubric)
392
+ complexity_target = self._extract_complexity(rubric)
393
+
394
+ factors = []
395
+ for _ in range(n):
396
+ self._counter += 1
397
+ expr = self._build_expression(features, ops, complexity_target)
398
+ factors.append(Factor(
399
+ id=f"f_{self._counter}",
400
+ expression=expr,
401
+ rubric_id=id(rubric), # temporary ID, will be overwritten
402
+ generation=0,
403
+ ))
404
+ return factors
405
+
406
+ # ── constraint extraction ──
407
+ def _extract_features(self, rubric: FactorRubric) -> List[str]:
408
+ feats = []
409
+ for item in rubric.items:
410
+ feats.extend(item.feature_focus)
411
+ return list(set(feats)) if feats else self.FEATURES
412
+
413
+ def _extract_ops(self, rubric: FactorRubric) -> List[str]:
414
+ ops = []
415
+ for item in rubric.items:
416
+ ops.extend(item.preferred_ops)
417
+ return list(set(ops)) if ops else self.OPS
418
+
419
+ def _extract_complexity(self, rubric: FactorRubric) -> int:
420
+ hints = [item.complexity_hint for item in rubric.items]
421
+ if "low" in hints:
422
+ return 2
423
+ if "high" in hints:
424
+ return 5
425
+ return 3 # default medium
426
+
427
+ # ── expression builder ──
428
+ def _build_expression(self, features, ops, complexity_target: int) -> str:
429
+ """Build a random expression of roughly target complexity."""
430
+ n_terms = max(1, complexity_target - 1 + self.rng.randint(-1, 2))
431
+ terms = []
432
+ for _ in range(n_terms):
433
+ terms.append(self._random_term(features, ops))
434
+ if len(terms) == 1:
435
+ return terms[0]
436
+ # Combine with binary ops
437
+ expr = terms[0]
438
+ for t in terms[1:]:
439
+ op = self.rng.choice([" + ", " - ", " * "])
440
+ expr = f"({expr}){op}({t})"
441
+ return expr
442
+
443
+ def _random_term(self, features, ops) -> str:
444
+ feat = self.rng.choice(features)
445
+ # Decide: raw feature, windowed operator, or cross-feature interaction
446
+ choice = self.rng.choice(["raw", "op", "interaction"], p=[0.25, 0.50, 0.25])
447
+ if choice == "raw":
448
+ return feat
449
+ if choice == "op":
450
+ op = self.rng.choice([o for o in ops if o.startswith("ts_") or o in ("rank", "abs", "sign", "log", "sqrt")])
451
+ if op.startswith("ts_") and op not in ("ts_corr", "ts_cov"):
452
+ w = self.rng.choice(self.WINDOWS)
453
+ return f"{op}({feat}, {w})"
454
+ if op in ("rank", "abs", "sign", "log", "sqrt"):
455
+ return f"{op}({feat})"
456
+ # fallback
457
+ return feat
458
+ # interaction
459
+ feat2 = self.rng.choice([f for f in features if f != feat])
460
+ op = self.rng.choice([" * ", " + ", " - "])
461
+ return f"{feat}{op}{feat2}"
462
+
463
+ # ═════════════════════════════════════════════════════════════════════════════
464
+ # 5. RUBRIC PROPOSER (MultiAIDE-style tree search)
465
+ # ═════════════════════════════════════════════════════════════════════════════
466
+
467
+ class RubricProposer:
468
+ """
469
+ Implements the APRES rubric-discovery loop:
470
+ Propose β†’ Generate Factors β†’ Evaluate via Regression β†’ Select & Refine
471
+ With MultiAIDE-style tree search:
472
+ β€’ N0 initial rubric branches
473
+ β€’ Each step: branch N new variants from best or buggy (prob p_debug)
474
+ β€’ Max debug depth D_max per node
475
+ """
476
+
477
+ # Seed rubrics to start the search
478
+ SEED_RUBRICS = [
479
+ FactorRubric([
480
+ RubricItem("price_momentum", "Prefer price-based momentum signals",
481
+ feature_focus=["close", "returns_1d", "returns_5d"],
482
+ preferred_ops=["ts_mean", "ts_delta", "rank"],
483
+ time_horizon_hint="short", complexity_hint="low"),
484
+ ]),
485
+ FactorRubric([
486
+ RubricItem("volume_confirm", "Use volume to confirm price signals",
487
+ feature_focus=["volume", "volume_sma_20", "returns_1d"],
488
+ preferred_ops=["ts_corr", "rank", "ts_mean"],
489
+ time_horizon_hint="medium", complexity_hint="medium"),
490
+ ]),
491
+ FactorRubric([
492
+ RubricItem("mean_reversion", "Capture mean-reversion patterns",
493
+ feature_focus=["close", "sma_20", "sma_10"],
494
+ preferred_ops=["ts_zscore", "ts_delta", "abs"],
495
+ time_horizon_hint="short", complexity_hint="medium"),
496
+ ]),
497
+ ]
498
+
499
+ def __init__(self,
500
+ N0: int = 3,
501
+ N: int = 3,
502
+ p_debug: float = 0.3,
503
+ D_max: int = 5,
504
+ seed: int = 77):
505
+ self.N0 = N0
506
+ self.N = N
507
+ self.p_debug = p_debug
508
+ self.D_max = D_max
509
+ self.rng = np.random.RandomState(seed)
510
+ self._debug_counts = defaultdict(int) # node_id β†’ debug attempts
511
+
512
+ def propose_initial(self) -> List[FactorRubric]:
513
+ """N0 diverse seed rubrics."""
514
+ rubrics = [copy.deepcopy(r) for r in self.SEED_RUBRICS[:self.N0]]
515
+ # Pad with randomised variants if fewer seeds than N0
516
+ while len(rubrics) < self.N0:
517
+ base = copy.deepcopy(self.rng.choice(self.SEED_RUBRICS))
518
+ rubrics.append(self._mutate_rubric(base))
519
+ return rubrics
520
+
521
+ def propose_from_parent(self, parent: FactorRubric, is_buggy: bool = False) -> List[FactorRubric]:
522
+ """Branch N new rubric variants from a parent."""
523
+ children = []
524
+ for _ in range(self.N):
525
+ child = self._mutate_rubric(copy.deepcopy(parent))
526
+ if is_buggy:
527
+ # More aggressive mutation for debug branches
528
+ child = self._mutate_rubric(child)
529
+ children.append(child)
530
+ return children
531
+
532
+ # ── mutation operators ──
533
+ def _mutate_rubric(self, rubric: FactorRubric) -> FactorRubric:
534
+ """Apply one structural mutation to a rubric."""
535
+ items = rubric.items
536
+ mutation = self.rng.choice(["add", "remove", "replace", "tweak"])
537
+
538
+ if mutation == "add" or len(items) == 0:
539
+ items.append(self._random_item())
540
+ elif mutation == "remove" and len(items) > 1:
541
+ items.pop(self.rng.randint(0, len(items)))
542
+ elif mutation == "replace" and len(items) > 0:
543
+ idx = self.rng.randint(0, len(items))
544
+ items[idx] = self._random_item()
545
+ elif mutation == "tweak" and len(items) > 0:
546
+ idx = self.rng.randint(0, len(items))
547
+ items[idx] = self._tweak_item(items[idx])
548
+
549
+ return FactorRubric(items)
550
+
551
+ def _random_item(self) -> RubricItem:
552
+ templates = [
553
+ ("momentum", "Focus on momentum signals",
554
+ ["close", "returns_5d", "returns_20d"], ["ts_mean", "ts_delta", "rank"]),
555
+ ("volatility", "Exploit volatility patterns",
556
+ ["volatility_20d", "returns_1d", "close"], ["ts_zscore", "ts_std", "abs"]),
557
+ ("volume_price", "Volume-price interaction",
558
+ ["volume", "close", "returns_1d"], ["ts_corr", "rank", "ts_mean"]),
559
+ ("cross_sectional", "Cross-sectional ranking",
560
+ ["close", "volume", "returns_1d"], ["rank", "ts_zscore", "ts_rank"]),
561
+ ("mean_reversion", "Mean-reversion",
562
+ ["close", "sma_10", "sma_20"], ["ts_zscore", "ts_delta", "sign"]),
563
+ ("breakout", "Breakout patterns",
564
+ ["high_20d", "low_20d", "close"], ["ts_max", "ts_min", "ts_delta"]),
565
+ ("vwap", "VWAP deviation",
566
+ ["vwap", "close", "volume"], ["ts_zscore", "ts_mean", "ts_corr"]),
567
+ ]
568
+ t = templates[self.rng.randint(0, len(templates))]
569
+ return RubricItem(
570
+ id=t[0], description=t[1],
571
+ feature_focus=t[2], preferred_ops=t[3],
572
+ time_horizon_hint=self.rng.choice(["short", "medium", "long"]),
573
+ complexity_hint=self.rng.choice(["low", "medium", "high"]),
574
+ weight=1.0,
575
+ )
576
+
577
+ def _tweak_item(self, item: RubricItem) -> RubricItem:
578
+ """Small perturbation of one rubric item."""
579
+ tweak = self.rng.choice(["feature", "op", "horizon", "complexity"])
580
+ if tweak == "feature" and item.feature_focus:
581
+ all_feats = ["close", "volume", "returns_1d", "returns_5d", "vwap",
582
+ "sma_10", "sma_20", "volatility_20d", "high_20d", "low_20d"]
583
+ item.feature_focus = list(set(
584
+ item.feature_focus + [self.rng.choice(all_feats)]
585
+ ))[:3]
586
+ elif tweak == "op" and item.preferred_ops:
587
+ all_ops = ["ts_mean", "ts_std", "ts_zscore", "ts_delta", "rank",
588
+ "ts_corr", "ts_rank", "abs", "sign", "log"]
589
+ item.preferred_ops = list(set(
590
+ item.preferred_ops + [self.rng.choice(all_ops)]
591
+ ))[:3]
592
+ elif tweak == "horizon":
593
+ item.time_horizon_hint = self.rng.choice(["short", "medium", "long"])
594
+ elif tweak == "complexity":
595
+ item.complexity_hint = self.rng.choice(["low", "medium", "high"])
596
+ return item
597
+
598
+ # ═════════════════════════════════════════════════════════════════════════════
599
+ # 6. MAIN AFRES SYSTEM (Rubric Discovery Loop)
600
+ # ═════════════════════════════════════════════════════════════════════════════
601
+
602
+ class AFRES:
603
+ """
604
+ Agentic Factor Revision and Evaluation System.
605
+
606
+ Phase 1 – Rubric Discovery (faithful to APRES Β§3.1):
607
+ 1. Propose: RubricProposer creates rubric variants
608
+ 2. Generate: LLMFactorGenerator produces factors per rubric
609
+ 3. Evaluate: RegressionEvaluator trains model, reports MAE
610
+ 4. Select&Refine: MultiAIDE tree search keeps / branches best rubrics
611
+
612
+ Phase 2 – Best-Rubric Factor Generation:
613
+ Use the discovered rubric to generate a final pool of factors.
614
+ """
615
+
616
+ def __init__(self,
617
+ data: MarketData,
618
+ generator: LLMFactorGenerator,
619
+ evaluator: RegressionEvaluator,
620
+ proposer: RubricProposer,
621
+ factors_per_rubric: int = 4):
622
+ self.data = data
623
+ self.generator = generator
624
+ self.evaluator = evaluator
625
+ self.proposer = proposer
626
+ self.k = factors_per_rubric
627
+
628
+ # Search state
629
+ self.best_rubric: Optional[FactorRubric] = None
630
+ self.best_fitness: float = -1e9
631
+ self.all_factors: List[Factor] = []
632
+ self.history: List[Dict] = []
633
+
634
+ def discover_rubric(self, max_iterations: int = 20) -> Tuple[FactorRubric, List[Factor]]:
635
+ """
636
+ Run the APRES-style rubric-discovery loop.
637
+
638
+ Returns the best discovered rubric and the best factors found under it.
639
+ """
640
+ print("=" * 70)
641
+ print(" PHASE 1: RUBRIC DISCOVERY (APRES-style MultiAIDE search)")
642
+ print("=" * 70)
643
+
644
+ # ── initial population ──
645
+ population = self.proposer.propose_initial()
646
+ scores = []
647
+ for rubric in population:
648
+ fitness, factors = self._evaluate_rubric(rubric)
649
+ scores.append((fitness, rubric, factors))
650
+ self.history.append({
651
+ "iteration": 0, "rubric_id": id(rubric),
652
+ "fitness": fitness, "n_factors": len(factors),
653
+ "mean_ic": np.mean([f.ic for f in factors]) if factors else 0.0,
654
+ "mean_mae": np.mean([f.mae for f in factors]) if factors else 1e6,
655
+ })
656
+
657
+ # Track best
658
+ best = max(scores, key=lambda x: x[0])
659
+ self.best_fitness, self.best_rubric, best_factors = best
660
+ print(f"\nInitial best fitness = {self.best_fitness:.4f} "
661
+ f"(IC={np.mean([f.ic for f in best_factors]):.4f}, "
662
+ f"MAE={np.mean([f.mae for f in best_factors]):.4f})")
663
+
664
+ # ── iterative tree search ──
665
+ for it in range(1, max_iterations + 1):
666
+ t0 = time.time()
667
+
668
+ # Pick parent: best with prob (1-p_debug), else random buggy
669
+ if self.proposer.rng.random() > self.proposer.p_debug:
670
+ parent = self.best_rubric
671
+ is_buggy = False
672
+ else:
673
+ # Pick a random previously-evaluated rubric (could be bad)
674
+ parent = self.proposer.rng.choice([s[1] for s in scores])
675
+ is_buggy = True
676
+
677
+ children = self.proposer.propose_from_parent(parent, is_buggy)
678
+
679
+ child_scores = []
680
+ for child in children:
681
+ fitness, factors = self._evaluate_rubric(child)
682
+ child_scores.append((fitness, child, factors))
683
+ self.history.append({
684
+ "iteration": it, "rubric_id": id(child),
685
+ "fitness": fitness, "n_factors": len(factors),
686
+ "mean_ic": np.mean([f.ic for f in factors]) if factors else 0.0,
687
+ "mean_mae": np.mean([f.mae for f in factors]) if factors else 1e6,
688
+ "buggy_branch": is_buggy,
689
+ })
690
+
691
+ # Update global best
692
+ local_best = max(child_scores, key=lambda x: x[0])
693
+ if local_best[0] > self.best_fitness:
694
+ self.best_fitness = local_best[0]
695
+ self.best_rubric = local_best[1]
696
+ best_factors = local_best[2]
697
+ print(f" Iter {it:02d}: NEW BEST fitness={self.best_fitness:.4f} "
698
+ f"IC={np.mean([f.ic for f in best_factors]):.4f} "
699
+ f"MAE={np.mean([f.mae for f in best_factors]):.4f} "
700
+ f"({time.time()-t0:.1f}s)")
701
+ else:
702
+ print(f" Iter {it:02d}: no improvement "
703
+ f"best={self.best_fitness:.4f} "
704
+ f"({time.time()-t0:.1f}s)")
705
+
706
+ scores.extend(child_scores)
707
+
708
+ print(f"\n{'='*70}")
709
+ print(f" RUBRIC DISCOVERY COMPLETE – best fitness = {self.best_fitness:.4f}")
710
+ print(f"{'='*70}")
711
+ self._print_rubric(self.best_rubric)
712
+ return self.best_rubric, best_factors
713
+
714
+ def _evaluate_rubric(self, rubric: FactorRubric) -> Tuple[float, List[Factor]]:
715
+ """
716
+ Evaluate a rubric:
717
+ 1. Generate k factor expressions
718
+ 2. Evaluate each on market data via regression
719
+ 3. Aggregate fitness (mean of valid factors)
720
+ """
721
+ rubric_id = f"rubric_{id(rubric)}"
722
+ factors = self.generator.generate(rubric, n=self.k)
723
+ for f in factors:
724
+ f.rubric_id = rubric_id
725
+
726
+ valid_factors = []
727
+ for f in factors:
728
+ self.evaluator.evaluate(f)
729
+ self.all_factors.append(f)
730
+ if f.valid:
731
+ valid_factors.append(f)
732
+
733
+ if not valid_factors:
734
+ return -1e6, factors # severe penalty for rubric that generates no valid factors
735
+
736
+ # Fitness = mean IC (higher = better)
737
+ # Could also use -MAE or a blend; IC is cleaner for ranking
738
+ fitness = float(np.mean([f.ic for f in valid_factors]))
739
+ return fitness, factors
740
+
741
+ def generate_final_pool(self, n: int = 20) -> List[Factor]:
742
+ """Generate a large factor pool from the best discovered rubric."""
743
+ if self.best_rubric is None:
744
+ raise RuntimeError("Run discover_rubric() first")
745
+ print(f"\n{'='*70}")
746
+ print(f" PHASE 2: FINAL FACTOR POOL (best rubric, n={n})")
747
+ print(f"{'='*70}")
748
+ factors = self.generator.generate(self.best_rubric, n=n)
749
+ for f in factors:
750
+ f.rubric_id = "best"
751
+ self.evaluator.evaluate(f)
752
+ self.all_factors.append(f)
753
+
754
+ valid = [f for f in factors if f.valid]
755
+ valid.sort(key=lambda f: f.ic, reverse=True)
756
+ return valid
757
+
758
+ @staticmethod
759
+ def _print_rubric(rubric: FactorRubric):
760
+ print("\nBest discovered rubric:")
761
+ for item in rubric.items:
762
+ print(f" β€’ {item.id}: {item.description}")
763
+ print(f" features={item.feature_focus} ops={item.preferred_ops} "
764
+ f"horizon={item.time_horizon_hint} complexity={item.complexity_hint}")
765
+
766
+ def summary(self) -> Dict:
767
+ """Produce a JSON-serialisable summary."""
768
+ valid_factors = [f for f in self.all_factors if f.valid]
769
+ if not valid_factors:
770
+ return {}
771
+
772
+ top = max(valid_factors, key=lambda f: f.ic)
773
+ return {
774
+ "best_rubric": self.best_rubric.to_dict() if self.best_rubric else None,
775
+ "best_fitness": float(self.best_fitness),
776
+ "total_factors_generated": len(self.all_factors),
777
+ "valid_factors": len(valid_factors),
778
+ "top_factor": top.to_dict(),
779
+ "mean_ic": float(np.mean([f.ic for f in valid_factors])),
780
+ "mean_mae": float(np.mean([f.mae for f in valid_factors])),
781
+ "mean_sharpe": float(np.mean([f.sharpe for f in valid_factors])),
782
+ "search_history": self.history,
783
+ }
784
+
785
+ # ═════════════════════════════════════════════════════════════════════════════
786
+ # 7. DRIVER
787
+ # ═════════════════════════════════════════════════════════════════════════════
788
+
789
+ def main():
790
+ print("\n" + "=" * 70)
791
+ print(" AFRES v2: Agentic Factor Revision and Evaluation System")
792
+ print(" (Faithful APRES rubric-discovery adapted to factor generation)")
793
+ print("=" * 70 + "\n")
794
+
795
+ t0 = time.time()
796
+
797
+ # 1. Market data
798
+ print("[1/4] Loading market data …")
799
+ data = MarketData(n_stocks=80, n_days=600, seed=42)
800
+
801
+ # 2. Components
802
+ print("[2/4] Initialising components …")
803
+ generator = LLMFactorGenerator(seed=99)
804
+ evaluator = RegressionEvaluator(data, model_type="ridge")
805
+ proposer = RubricProposer(N0=3, N=3, p_debug=0.3, D_max=5, seed=77)
806
+
807
+ # 3. AFRES system
808
+ afres = AFRES(data, generator, evaluator, proposer, factors_per_rubric=4)
809
+
810
+ # 4. Run rubric discovery
811
+ print("[3/4] Running rubric-discovery loop …")
812
+ best_rubric, best_factors = afres.discover_rubric(max_iterations=15)
813
+
814
+ # 5. Final pool
815
+ print("\n[4/4] Generating final factor pool …")
816
+ final_pool = afres.generate_final_pool(n=20)
817
+
818
+ # 6. Report
819
+ print("\n" + "=" * 70)
820
+ print(" FINAL REPORT")
821
+ print("=" * 70)
822
+ print(f"\nTotal factors evaluated: {len(afres.all_factors)}")
823
+ print(f"Valid factors: {len([f for f in afres.all_factors if f.valid])}")
824
+ print(f"\nTop 5 factors by IC:")
825
+ for f in final_pool[:5]:
826
+ print(f" {f.id:8s} IC={f.ic:+.4f} MAE={f.mae:.4f} "
827
+ f"Sharpe={f.sharpe:6.2f} {f.expression}")
828
+
829
+ # 7. Save
830
+ results = afres.summary()
831
+ with open("/app/afres_v2_results.json", "w") as fp:
832
+ json.dump(results, fp, indent=2, default=str)
833
+ print(f"\nResults saved to /app/afres_v2_results.json")
834
+ print(f"Total wall time: {time.time()-t0:.1f}s")
835
+
836
+
837
+ if __name__ == "__main__":
838
+ main()