gaurv007 commited on
Commit
3ef59fc
·
verified ·
1 Parent(s): 109d051

Upload alpha_factory/local/brain_sim.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. alpha_factory/local/brain_sim.py +180 -0
alpha_factory/local/brain_sim.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Local BRAIN Simulator — Layer 4 (the killer feature).
3
+ Mimics BRAIN's IS tests locally using free price data.
4
+ Rejects obvious losers BEFORE spending BRAIN credits.
5
+ Saves 30-50% of submissions.
6
+ """
7
+ import numpy as np
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class LocalSimResult:
14
+ """Result from local simulation."""
15
+ sharpe: float
16
+ turnover: float
17
+ returns: float
18
+ fitness: float
19
+ sub_universe_sharpe_p10: float
20
+ max_drawdown: float
21
+ would_pass_brain: bool
22
+ rejection_reasons: list[str]
23
+
24
+
25
+ def simulate_alpha_local(
26
+ signal_scores: np.ndarray,
27
+ returns: np.ndarray,
28
+ min_sharpe: float = 1.0,
29
+ min_fitness: float = 0.7,
30
+ max_turnover: float = 0.70,
31
+ min_turnover: float = 0.01,
32
+ ) -> LocalSimResult:
33
+ """
34
+ Run a local quick-and-dirty backtest to triage alphas before BRAIN submission.
35
+
36
+ Args:
37
+ signal_scores: (T, N) array — daily signal/score for each stock
38
+ returns: (T, N) array — daily returns for each stock
39
+ min_sharpe: minimum Sharpe to pass local sim
40
+ min_fitness: minimum fitness to pass local sim
41
+ max_turnover: maximum turnover allowed
42
+ min_turnover: minimum turnover (too low = no trading)
43
+
44
+ Returns:
45
+ LocalSimResult with pass/fail verdict
46
+
47
+ Note: BRAIN's actual prices differ from free sources by 5-15% Sharpe.
48
+ This is for TRIAGE only — tells you if you're in the ballpark.
49
+ """
50
+ T, N = signal_scores.shape
51
+ rejection_reasons = []
52
+
53
+ # Normalize signals to weights (cross-sectional rank → dollar-neutral)
54
+ weights = np.zeros_like(signal_scores)
55
+ for t in range(T):
56
+ row = signal_scores[t]
57
+ valid = ~np.isnan(row)
58
+ if valid.sum() < 50:
59
+ continue
60
+ ranked = np.zeros(N)
61
+ ranked[valid] = _rank_normalize(row[valid])
62
+ # Dollar-neutral: demean
63
+ ranked -= ranked.mean()
64
+ # Normalize to unit leverage
65
+ abs_sum = np.abs(ranked).sum()
66
+ if abs_sum > 0:
67
+ weights[t] = ranked / abs_sum
68
+
69
+ # PnL
70
+ # Shift weights by 1 day (you trade on today's signal, get tomorrow's return)
71
+ pnl = (weights[:-1] * returns[1:]).sum(axis=1)
72
+
73
+ if len(pnl) == 0 or np.std(pnl) == 0:
74
+ return LocalSimResult(
75
+ sharpe=0, turnover=0, returns=0, fitness=0,
76
+ sub_universe_sharpe_p10=0, max_drawdown=0,
77
+ would_pass_brain=False,
78
+ rejection_reasons=["No valid PnL computed"]
79
+ )
80
+
81
+ # Sharpe
82
+ sharpe = np.mean(pnl) / np.std(pnl) * np.sqrt(252)
83
+
84
+ # Turnover
85
+ weight_diffs = np.abs(weights[1:] - weights[:-1]).sum(axis=1)
86
+ weight_sums = np.abs(weights[:-1]).sum(axis=1)
87
+ valid_turns = weight_sums > 0
88
+ turnover = np.mean(weight_diffs[valid_turns] / weight_sums[valid_turns]) if valid_turns.any() else 0
89
+
90
+ # Returns
91
+ total_returns = pnl.sum()
92
+
93
+ # Fitness = Sharpe * sqrt(|returns| / turnover)
94
+ fitness = sharpe * np.sqrt(abs(total_returns) / max(turnover, 0.001)) if turnover > 0 else 0
95
+
96
+ # Max drawdown
97
+ cum_pnl = np.cumsum(pnl)
98
+ running_max = np.maximum.accumulate(cum_pnl)
99
+ drawdowns = running_max - cum_pnl
100
+ max_drawdown = drawdowns.max() if len(drawdowns) > 0 else 0
101
+
102
+ # Sub-universe Sharpe (simulate BRAIN's sub-universe check)
103
+ sub_sharpes = []
104
+ for _ in range(20):
105
+ idx = np.random.choice(N, size=min(1000, N), replace=False)
106
+ sub_pnl = (weights[:-1, idx] * returns[1:, idx]).sum(axis=1)
107
+ if np.std(sub_pnl) > 0:
108
+ sub_sharpes.append(np.mean(sub_pnl) / np.std(sub_pnl) * np.sqrt(252))
109
+ sub_p10 = np.percentile(sub_sharpes, 10) if sub_sharpes else 0
110
+
111
+ # Verdict
112
+ if sharpe < min_sharpe:
113
+ rejection_reasons.append(f"Sharpe {sharpe:.2f} < {min_sharpe}")
114
+ if fitness < min_fitness:
115
+ rejection_reasons.append(f"Fitness {fitness:.2f} < {min_fitness}")
116
+ if turnover > max_turnover:
117
+ rejection_reasons.append(f"Turnover {turnover:.2f} > {max_turnover}")
118
+ if turnover < min_turnover:
119
+ rejection_reasons.append(f"Turnover {turnover:.4f} < {min_turnover} (no trading)")
120
+ if sub_p10 < 0.2:
121
+ rejection_reasons.append(f"Sub-universe Sharpe p10 {sub_p10:.2f} < 0.2")
122
+
123
+ would_pass = len(rejection_reasons) == 0
124
+
125
+ return LocalSimResult(
126
+ sharpe=round(sharpe, 4),
127
+ turnover=round(turnover, 4),
128
+ returns=round(total_returns, 4),
129
+ fitness=round(fitness, 4),
130
+ sub_universe_sharpe_p10=round(sub_p10, 4),
131
+ max_drawdown=round(max_drawdown, 4),
132
+ would_pass_brain=would_pass,
133
+ rejection_reasons=rejection_reasons,
134
+ )
135
+
136
+
137
+ def correlation_with_returns(signal: np.ndarray, returns: np.ndarray) -> float:
138
+ """
139
+ Layer 5: Quick correlation check.
140
+ If |corr| > 0.95 → momentum mirror (kill).
141
+ If |corr| < 0.05 → orthogonal to price (interesting).
142
+ """
143
+ flat_signal = signal.flatten()
144
+ flat_returns = returns.flatten()
145
+ valid = ~(np.isnan(flat_signal) | np.isnan(flat_returns))
146
+ if valid.sum() < 100:
147
+ return 0.0
148
+ return float(np.corrcoef(flat_signal[valid], flat_returns[valid])[0, 1])
149
+
150
+
151
+ def sign_sweep_local(
152
+ signal: np.ndarray,
153
+ returns: np.ndarray,
154
+ ) -> dict:
155
+ """
156
+ Layer 3: Local sign sweep.
157
+ Test both directions of the alpha to determine correct sign.
158
+ """
159
+ pos_result = simulate_alpha_local(signal, returns)
160
+ neg_result = simulate_alpha_local(-signal, returns)
161
+
162
+ info_value = abs(pos_result.sharpe - neg_result.sharpe)
163
+ verdict = "pos" if pos_result.sharpe > neg_result.sharpe else "neg"
164
+
165
+ return {
166
+ "pos_sharpe": pos_result.sharpe,
167
+ "neg_sharpe": neg_result.sharpe,
168
+ "info_value": round(info_value, 4),
169
+ "verdict": verdict,
170
+ "has_signal": info_value > 0.3,
171
+ }
172
+
173
+
174
+ def _rank_normalize(arr: np.ndarray) -> np.ndarray:
175
+ """Convert values to ranks normalized to [-1, 1]."""
176
+ from scipy.stats import rankdata
177
+ ranks = rankdata(arr, method='average')
178
+ # Normalize to [-1, 1]
179
+ n = len(ranks)
180
+ return 2 * (ranks - 1) / (n - 1) - 1