forked from UNSW-FinTech-Society-IT/algothon26-starter-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdevtokens.py
More file actions
713 lines (623 loc) · 26.7 KB
/
Copy pathdevtokens.py
File metadata and controls
713 lines (623 loc) · 26.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
import numpy as np
DEFAULT_DLR_LIMIT = 10_000
ALGO_DLR_LIMIT = 100_000
# --- residual sleeve ---
FACTOR_WIN = 40 # trailing days for PCA factor estimation
PCA_K = 2 # common factors removed
Z_WIN = 20 # reversion horizon (z-score window)
# --- lead-lag sleeve ---
# Fit B on up to TRAIN_WIN days, but start trading once LL_MIN_HISTORY is available
# (waiting for a full TRAIN_WIN window would delay the sleeve too long).
LL_MIN_HISTORY = 380 # days of history before the lead-lag sleeve activates
TRAIN_WIN = 500 # trailing days used to fit B (clipped to available history)
REFIT = 25 # refit B every this many days
RIDGE_LAM = 1.0 # ridge penalty on B
LL_FULL_Z = 1.5 # z-score that saturates lead-lag confidence to +/-1
LL_VOL_TILT = 0.5 # low-vol tilt power: weight LL toward low-vol names
LL_VOL_WIN = 60 # trailing days for the per-name vol used by the tilt
# Shrink B toward its rank-k structure + temporal smooth with previous refit
B_LR_K = 4 # keep top-k singular components of B
B_LR_ALPHA = 0.7 # blend weight on low-rank B
B_TEMP_ALPHA = 0.7 # blend weight on previous refit's B
# --- ALGO cointegration-pairs overlay ---
PAIRS_WIN = 120 # trailing days for Engle-Granger pair discovery
PAIRS_Z_WIN = 60 # z-score window on the spread
PAIRS_REFIT = 25 # rediscover pairs every this many days
PAIRS_ENTRY_Z = 2.0 # |z| needed to open a spread trade
PAIRS_TMAX = -3.0 # Dickey-Fuller t-stat threshold (more negative = cointegrated)
PAIRS_PMAX = 8 # max pairs held
PAIRS_ALLOC = 0.5 # per-pair capital multiplier (before W_PAIRS)
W_PAIRS = 2.0 # weight on ALGO-pairs overlay
# --- broader cointegration-pairs overlay — all NON-ALGO pairs ---
# Rolling discovery over all i<j (i,j>=1); continuous z-fade, fixed $/leg.
PAIRS2_WIN = 400 # trailing days for pair discovery
PAIRS2_REFIT = 50 # rediscover pairs every this many days
PAIRS2_TMAX = -3.5 # Dickey-Fuller t-stat threshold (strict)
PAIRS2_PMAX = 12 # max pairs held
PAIRS2_Z_WIN = 20 # z-score window on the spread
PAIRS2_FULL_Z = 1.5 # |z| that saturates the fade to +/-1
PAIRS2_DOLLARS = 16000.0 # capital per leg (grader clips to $10k)
# --- high-hit pairs extra-conviction overlay ---
# Extra sign-based size when |z| >= HH_ZMIN.
HH_PAIRS = (
(49, 50, 0.99), # MHRM-EAFC
(18, 35, 0.98), # RTTH-NAYO
(1, 20, 0.99), # AENO-NWIG
(7, 40, 0.84), # HETT-ULXY
(31, 43, 0.88), # ACIX-ITPA
(36, 41, 0.96), # FWWG-BLBT (also in pairs2 — doubles when stretched)
)
HH_ZMIN = 1.5 # |z| needed to fire the extra conviction
HH_Z_WIN = 20 # z-score window on the spread
HH_DOLLARS = 10000.0 # extra $ per leg when fired
# --- blend + sizing ---
W_LL = 0.75 # weight on the lead-lag core
W_RESID = 0.25 # weight on the residual diversifier
# Per-name 20d momentum orthogonalised to lead-lag
W_ORTHOG_TSMOM20 = 0.15
ORTHOG_TSMOM20_LOOK = 20 # days in the name momentum window
# Shorter 10d mom, residualised vs LL and raw 20d
W_ORTHOG_TSMOM10 = 0.08
ORTHOG_TSMOM10_LOOK = 10
# Fade yesterday's B forecast error, residualised vs today's r̂ and orthog-20d
W_ERR_REVERT = 0.09
# Fade 60d per-name return, orthogonalised to LL + orthog-20d
W_FADE60 = 0.10
FADE60_LOOK = 60
# Fade price vs 100d MA, orthog to LL + o20 + fade60
W_FADE_MA100 = 0.10
FADE_MA100 = 100
ALLOC = 5.0 # capital multiplier (grader clips to real limits)
# Scale blended conf by inverse EWMA vol before share mapping
INVVOL_ENABLE = True
INVVOL_SPAN = 20 # EWMA span for per-name vol
INVVOL_POWER = 0.35 # (1/σ)^power
# Condition LL prediction input r_t by activity proxy |r|/EWMA_σ (not real volume).
# B fit unchanged; only last @ B uses scaled returns.
ACT_PROXY_ENABLE = True
ACT_PROXY_GAMMA = 0.25
ACT_PROXY_VMIN = 0.5
ACT_PROXY_VMAX = 2.0
ACT_PROXY_VOL_SPAN = 20
# Scale LL conf by per-asset in-sample R² of B (vs median)
LL_R2_ENABLE = True
LL_R2_POWER = 0.15
LL_R2_VMIN = 0.5
LL_R2_VMAX = 1.5
# --- Finals-only trend overlay (luck bet — not validated OOS) ---
# Fires only when history length > FINALS_TREND_START (day 1001+). Local eval on
# days 1–1000 is unchanged. Drift mu is frozen from the first 1000 days; longs
# the strongest positive-drift names. Concordance floor: only push to +$D when
# the core book is not already short that name (won't fight MN shorts).
FINALS_TREND_ENABLE = True # set False to disable
FINALS_TREND_START = 1000 # overlay inactive while nt <= this
FINALS_TREND_TOP_K = 7 # strongest-up names (ANSO dropped)
FINALS_TREND_MIN_ANN = 0.05 # require ~+5%/yr historical drift (skip weak "ups")
FINALS_TREND_DOLLARS = 10000.0 # $ per selected name (grader clips to $10k)
FINALS_TREND_EXCLUDE = (16,) # ANSO — manual veto
FINALS_TREND_CONCORD_DEADBAND = 2000.0 # skip floor if core $ < -deadband
# --- ALGO-only hysteresis ---
# Don't move ALGO unless the desired dollar change is >= FRAC * $100k cap.
ALGO_HYST_ENABLE = True
ALGO_HYST_FRAC = 0.55 # ~$55k deadband on ALGO position changes
# --- EW market-momentum index bet ---
# After dollar-neutral demean, add market-momentum tilts to every name's conf and
# do NOT re-demean → intentional net long/short market exposure.
INDEX_BET_ENABLE = True
INDEX_BET_STRENGTH = 0.10 # 1-day EW market mom
INDEX_BET_TSMOM20 = 0.05 # 20-day EW market trend stacked on top
INDEX_BET_LONG_MULT = 1.5 # asym long: multiply 1d strength on up days only
INDEX_BET_WIN = 60 # trailing days used to form EW market returns
# module-level caches so heavy fits only refit every REFIT days across grader calls
_LL_CACHE = {"nt": -1, "B": None, "B_prev": None, "r2_w": None}
_PAIRS_CACHE = {"nt": -1, "pairs": []}
_PAIRS2_CACHE = {"nt": -1, "pairs": []}
_TREND_CACHE = {"ready": False, "long_idx": ()}
_ALGO_HYST_CACHE = {"shares": None, "nt": -1}
def _dollar_limits(n):
lim = np.full(n, DEFAULT_DLR_LIMIT, dtype=float)
lim[0] = ALGO_DLR_LIMIT
return lim
def _neutral_clip(sig):
return np.clip(sig - sig.mean(), -1.0, 1.0)
def _conf_resid(prc):
n, nt = prc.shape
if nt < FACTOR_WIN + 2:
return np.zeros(n)
logp = np.log(prc[:, -(FACTOR_WIN + 1):])
rets = np.diff(logp, axis=1)
X = rets - rets.mean(1, keepdims=True)
U, S, Vt = np.linalg.svd(X, full_matrices=False)
kk = min(PCA_K, len(S))
resid = X - (U[:, :kk] @ np.diag(S[:kk]) @ Vt[:kk])
sp = resid.cumsum(1)
w = min(Z_WIN, sp.shape[1])
z = (sp[:, -1] - sp[:, -w:].mean(1)) / np.maximum(sp[:, -w:].std(1), 1e-8)
return _neutral_clip(-z)
def _lowrank_B(B, k):
"""Rank-k SVD truncation of B (keeps the dominant lead-lag factors)."""
U, S, Vt = np.linalg.svd(B, full_matrices=False)
k = min(k, len(S))
return (U[:, :k] * S[:k]) @ Vt[:k]
def _estimate_B(R, B_prev=None):
"""Y-vol-standardised ridge B, shrunk toward low-rank structure and previous fit.
Fit By on Y/vol, blend with rank-k(By), then unstandardise back to raw returns.
"""
vol = R.std(0) + 1e-8
X = R[:-1]
Y = R[1:] / vol
n = R.shape[1]
By = np.linalg.solve(X.T @ X + RIDGE_LAM * np.eye(n), X.T @ Y)
if B_LR_ALPHA > 0:
By = (1.0 - B_LR_ALPHA) * By + B_LR_ALPHA * _lowrank_B(By, B_LR_K)
B = By * vol
if B_prev is not None and B_TEMP_ALPHA > 0:
B = (1.0 - B_TEMP_ALPHA) * B + B_TEMP_ALPHA * B_prev
return B
def _lowvol_weights(prc, power=LL_VOL_TILT, win=LL_VOL_WIN):
"""Per-name tilt >1 for low-vol names, <1 for high-vol (causal trailing vol)."""
n, nt = prc.shape
w = min(win, nt - 1)
if w < 2:
return np.ones(n)
r = np.diff(np.log(prc[:, -(w + 1):]), axis=1)
vol = r.std(1) + 1e-8
return (vol / np.median(vol)) ** (-power)
def _invalidate_ll_cache():
_LL_CACHE.update({"nt": -1, "B": None, "B_prev": None, "r2_w": None})
def _maybe_rewind_caches(nt):
"""If grader rewinds / restarts with a shorter history, drop stateful caches.
Without this, a B (or pairs list) fitted at a larger nt silently encodes
returns after the rewound day — look-ahead. Hysteresis has its own guard.
"""
if _LL_CACHE["nt"] >= 0 and nt < _LL_CACHE["nt"]:
_invalidate_ll_cache()
if _PAIRS_CACHE["nt"] >= 0 and nt < _PAIRS_CACHE["nt"]:
_PAIRS_CACHE["nt"] = -1
_PAIRS_CACHE["pairs"] = []
if _PAIRS2_CACHE["nt"] >= 0 and nt < _PAIRS2_CACHE["nt"]:
_PAIRS2_CACHE["nt"] = -1
_PAIRS2_CACHE["pairs"] = []
if nt <= FINALS_TREND_START and _TREND_CACHE["ready"]:
_TREND_CACHE["ready"] = False
_TREND_CACHE["long_idx"] = ()
def _conf_leadlag(prc):
n, nt = prc.shape
# Activate at LL_MIN_HISTORY, not TRAIN_WIN — otherwise a long train window
# delays the sleeve until much later history is available.
if nt < LL_MIN_HISTORY + 5:
return np.zeros(n)
if _LL_CACHE["B"] is None or nt - _LL_CACHE["nt"] >= REFIT:
use = min(TRAIN_WIN, nt - 1) # grow to 500 as history arrives
logp = np.log(prc[:, -(use + 1):])
R = np.diff(logp, axis=1).T # use x n
_LL_CACHE["B_prev"] = _LL_CACHE["B"]
_LL_CACHE["B"] = _estimate_B(R, _LL_CACHE["B_prev"])
_LL_CACHE["nt"] = nt
# Per-asset in-sample R² of B on the fit window → LL size weights
if LL_R2_ENABLE and LL_R2_POWER != 0.0:
X, Y = R[:-1], R[1:]
Yhat = X @ _LL_CACHE["B"]
ss_res = np.sum((Y - Yhat) ** 2, axis=0)
ss_tot = np.sum((Y - Y.mean(0)) ** 2, axis=0) + 1e-18
r2 = np.clip(1.0 - ss_res / ss_tot, 0.0, None)
tilde = r2 / (float(np.median(r2)) + 1e-12)
_LL_CACHE["r2_w"] = np.clip(
tilde ** LL_R2_POWER, LL_R2_VMIN, LL_R2_VMAX)
else:
_LL_CACHE["r2_w"] = None
last = np.log(prc[:, -1]) - np.log(prc[:, -2]) # most recent completed return
# Activity-proxy input conditioning (prediction only — B fit unchanged).
# Scale each name's return by clip((|r|/σ / median)^γ) so large-vs-own-vol
# moves get more weight in y = B @ r. Not institutional volume.
if ACT_PROXY_ENABLE and ACT_PROXY_GAMMA != 0.0:
vol = _ewma_vol(prc, ACT_PROXY_VOL_SPAN)
v = np.abs(last) / vol
v = v / (np.median(v) + 1e-12)
last = last * np.clip(v ** ACT_PROXY_GAMMA, ACT_PROXY_VMIN, ACT_PROXY_VMAX)
pred = last @ _LL_CACHE["B"] # predicted next-day return
z = (pred - pred.mean()) / (pred.std() + 1e-12)
sig = _neutral_clip(np.clip(z / LL_FULL_Z, -1.0, 1.0))
# Low-vol tilt: reweight toward names where the edge is strongest
ll = _neutral_clip(sig * _lowvol_weights(prc))
# Damp LL on names B explains poorly in-sample; boost high-R² names
if LL_R2_ENABLE and _LL_CACHE["r2_w"] is not None:
ll = _neutral_clip(ll * _LL_CACHE["r2_w"])
return ll
def _ols(y, x):
"""OLS of y on [1, x]; returns (intercept, slope, resid)."""
A = np.column_stack([np.ones_like(x), x])
coef, *_ = np.linalg.lstsq(A, y, rcond=None)
return coef[0], coef[1], y - A @ coef
def _tls(y, x):
"""Total least squares / orthogonal regression: y ≈ a + b x.
Minimises orthogonal distance to the line (both series treated as noisy),
which reduces attenuation bias vs OLS when X is measured with error.
Used only for non-ALGO pairs2 — ALGO pairs stay on _ols.
"""
x0, y0 = x - x.mean(), y - y.mean()
_, _, Vt = np.linalg.svd(np.column_stack([x0, y0]), full_matrices=False)
v = Vt[-1]
if abs(v[1]) < 1e-12:
return float(y.mean()), 0.0, y - y.mean()
beta = -v[0] / v[1]
alpha = float(y.mean() - beta * x.mean())
return alpha, beta, y - alpha - beta * x
def _df_tstat(s):
"""Dickey-Fuller t-stat: regress ds on [1, s_lag]; t of the s_lag coef.
More negative -> spread is stationary -> the pair is cointegrated."""
ds = np.diff(s)
slag = s[:-1]
A = np.column_stack([np.ones_like(slag), slag])
coef, *_ = np.linalg.lstsq(A, ds, rcond=None)
resid = ds - A @ coef
dof = len(ds) - 2
if dof <= 0:
return 0.0
sigma2 = (resid @ resid) / dof
se = np.sqrt(sigma2 * np.linalg.inv(A.T @ A)[1, 1])
return coef[1] / se if se > 1e-12 else 0.0
def _find_pairs(prc):
"""ALGO-only (inst 0) cointegration pairs, ranked by DF t-stat."""
n, nt = prc.shape
w = min(PAIRS_WIN, nt)
lh = np.log(prc[:, -w:])
cand = []
for j in range(1, n):
_, beta, _ = _ols(lh[0], lh[j])
spread = lh[0] - beta * lh[j]
t = _df_tstat(spread)
if t <= PAIRS_TMAX:
cand.append((t, 0, j, beta))
cand.sort()
return cand[:PAIRS_PMAX]
def _pairs_positions(prc):
"""ALGO cointegration-pairs overlay → integer share vector."""
n, nt = prc.shape
pos = np.zeros(n, dtype=int)
if nt < PAIRS_WIN:
return pos
if _PAIRS_CACHE["nt"] >= 0 and nt < _PAIRS_CACHE["nt"]:
_PAIRS_CACHE["nt"] = -1
_PAIRS_CACHE["pairs"] = []
if _PAIRS_CACHE["nt"] < 0 or nt - _PAIRS_CACHE["nt"] >= PAIRS_REFIT:
_PAIRS_CACHE["pairs"] = _find_pairs(prc)
_PAIRS_CACHE["nt"] = nt
pairs = _PAIRS_CACHE["pairs"]
if not pairs:
return pos
px = prc[:, -1]
lim = _dollar_limits(n)
lh = np.log(prc[:, -PAIRS_WIN:])
ws = sum(abs(t) for t, _, _, _ in pairs) or 1.0
zw = min(PAIRS_Z_WIN, lh.shape[1])
for t, i, j, beta in pairs:
sp = lh[i] - beta * lh[j]
z = (sp[-1] - sp[-zw:].mean()) / max(sp[-zw:].std(), 1e-8)
if abs(z) < PAIRS_ENTRY_Z or not np.isfinite(beta) or abs(beta) < 1e-6:
continue
wgt = abs(t) / ws * len(pairs)
ui = int(lim[i] * PAIRS_ALLOC * wgt / px[i])
uj = int(np.clip(int(ui * beta), -lim[j], lim[j]))
if ui == 0 or uj == 0:
continue
if z > 0:
pos[i] -= ui; pos[j] += uj
else:
pos[i] += ui; pos[j] -= uj
return pos
def _find_pairs_all(prc):
"""All non-ALGO cointegration pairs (i<j, both >=1), ranked by DF t-stat.
Fit β with TLS (orthogonal regression), not OLS. ALGO pairs stay on OLS.
"""
n, nt = prc.shape
w = min(PAIRS2_WIN, nt)
lh = np.log(prc[:, -w:])
cand = []
for i in range(1, n):
for j in range(i + 1, n):
_, beta, spread = _tls(lh[i], lh[j]) # TLS residual + β
if beta <= 0:
continue
t = _df_tstat(spread)
if t <= PAIRS2_TMAX:
cand.append((t, i, j, beta))
cand.sort()
return cand[:PAIRS2_PMAX]
def _pairs2_positions(prc):
"""Broader non-ALGO cointegration-pairs overlay → share vector.
Continuous z-fade of each spread, fixed $ per leg. Betas from TLS discovery.
"""
n, nt = prc.shape
pos = np.zeros(n)
if nt < PAIRS2_WIN:
return pos
if _PAIRS2_CACHE["nt"] >= 0 and nt < _PAIRS2_CACHE["nt"]:
_PAIRS2_CACHE["nt"] = -1
_PAIRS2_CACHE["pairs"] = []
if _PAIRS2_CACHE["nt"] < 0 or nt - _PAIRS2_CACHE["nt"] >= PAIRS2_REFIT:
_PAIRS2_CACHE["pairs"] = _find_pairs_all(prc)
_PAIRS2_CACHE["nt"] = nt
pairs = _PAIRS2_CACHE["pairs"]
if not pairs:
return pos
px = prc[:, -1]
lp = np.log(prc)
zw = min(PAIRS2_Z_WIN, nt)
for t, i, j, beta in pairs:
if not np.isfinite(beta):
continue
sp = lp[i] - beta * lp[j]
z = (sp[-1] - sp[-zw:].mean()) / max(sp[-zw:].std(), 1e-8)
s = -np.clip(z / PAIRS2_FULL_Z, -1.0, 1.0) # fade: rich leg short, cheap leg long
pos[i] += s * PAIRS2_DOLLARS / px[i]
pos[j] += -s * beta * PAIRS2_DOLLARS / px[j]
return pos
def _hh_pairs_positions(prc):
"""Extra sign-based conviction on HH_PAIRS spreads at |z| >= HH_ZMIN."""
n, nt = prc.shape
pos = np.zeros(n)
if nt < HH_Z_WIN + 2:
return pos
px = prc[:, -1]
lp = np.log(prc)
for i, j, beta in HH_PAIRS:
if i >= n or j >= n:
continue
sp = lp[i] - beta * lp[j]
w = sp[-HH_Z_WIN:]
z = (w[-1] - w.mean()) / (w.std() + 1e-9)
if abs(z) >= HH_ZMIN:
s = -np.sign(z) # fade the stretch
pos[i] += s * HH_DOLLARS / px[i]
pos[j] += -s * abs(beta) * HH_DOLLARS / px[j]
return pos
def _mr_pair_members():
"""Instrument indices used by any cointegration / HH mean-reversion sleeve.
Residual MR (_conf_resid) hits every name — excluding on that would empty
the trend list. Only block explicit pairs / HH overlays.
"""
members = set()
for i, j, _ in HH_PAIRS:
members.add(int(i))
members.add(int(j))
for pairs in (_PAIRS_CACHE.get("pairs") or [], _PAIRS2_CACHE.get("pairs") or []):
for item in pairs:
members.add(int(item[1]))
members.add(int(item[2]))
return members
def _finals_trend_idx(prc):
"""Freeze the Finals long list from days 1–FINALS_TREND_START (once).
Skips names that are active legs in pairs/HH mean-reversion (don't long a
name the book is also fading as a cointegrated spread).
"""
n, nt = prc.shape
if (not FINALS_TREND_ENABLE) or nt <= FINALS_TREND_START:
# Rewind / pre-Finals: drop any frozen list from a prior longer run.
if _TREND_CACHE["ready"]:
_TREND_CACHE["ready"] = False
_TREND_CACHE["long_idx"] = ()
return ()
if not _TREND_CACHE["ready"]:
hist = prc[:, :FINALS_TREND_START]
rets = np.diff(np.log(hist), axis=1)
ann = np.exp(rets.mean(1) * 250) - 1.0
blocked = _mr_pair_members() | set(FINALS_TREND_EXCLUDE)
# skip ALGO + pairs/HH legs + manual vetoes; keep clear positive drift
cand = [
i for i in range(1, n)
if ann[i] >= FINALS_TREND_MIN_ANN and i not in blocked
]
cand.sort(key=lambda i: ann[i], reverse=True)
_TREND_CACHE["long_idx"] = tuple(cand[:FINALS_TREND_TOP_K])
_TREND_CACHE["ready"] = True
return _TREND_CACHE["long_idx"]
def _apply_finals_trend_floor(total, prc):
"""Finals-only luck bet: concordance floor on strongest historical ups.
Push to +$FINALS_TREND_DOLLARS only when the core book is already flat/long
(or only mildly short within CONCORD_DEADBAND). If the MN book wants a
clear short, leave it — do not override. Inactive while nt <= START.
"""
idx = _finals_trend_idx(prc)
if not idx:
return total
total = total.copy()
px = prc[:, -1]
for i in idx:
if i >= len(total) or px[i] <= 0:
continue
cur = total[i] * px[i]
if cur >= -FINALS_TREND_CONCORD_DEADBAND and cur < FINALS_TREND_DOLLARS:
total[i] = FINALS_TREND_DOLLARS / px[i]
return total
def _apply_algo_hysteresis(total, px, nt):
"""Hold ALGO unless the desired change clears ALGO_HYST_FRAC of the $100k cap.
Stateful across grader day-calls. If history rewinds (new backtest), reset.
"""
if not ALGO_HYST_ENABLE:
return total
total = total.copy()
desired = float(total[0])
prev = _ALGO_HYST_CACHE["shares"]
prev_nt = _ALGO_HYST_CACHE["nt"]
if prev is None or nt <= prev_nt:
_ALGO_HYST_CACHE["shares"] = desired
_ALGO_HYST_CACHE["nt"] = nt
return total
if abs(desired - prev) * float(px[0]) < ALGO_HYST_FRAC * ALGO_DLR_LIMIT:
total[0] = prev
else:
total[0] = desired
_ALGO_HYST_CACHE["shares"] = float(total[0])
_ALGO_HYST_CACHE["nt"] = nt
return total
def _apply_index_bet(conf, prc):
"""Net market tilts from EW momentum (1-day asymmetric + 20-day).
conf is assumed already cross-sectionally demeaned. Constant tilts are added
without re-demeaning, so the book takes intentional net market risk.
On up-market days the 1d tilt uses STRENGTH * LONG_MULT; down days keep
plain STRENGTH.
"""
if not INDEX_BET_ENABLE:
return conf
n, nt = prc.shape
if nt < 3:
return conf
w = min(INDEX_BET_WIN, nt - 1)
r = np.diff(np.log(prc[:, -(w + 1):]), axis=1) # n x w
mkt = r.mean(0) # equal-weight market returns
# 1-day momentum (stronger on the long side)
if abs(mkt[-1]) > 1e-15 and INDEX_BET_STRENGTH:
s1 = INDEX_BET_STRENGTH
if mkt[-1] > 0 and INDEX_BET_LONG_MULT > 1.0:
s1 = INDEX_BET_STRENGTH * INDEX_BET_LONG_MULT
conf = np.clip(conf + np.sign(mkt[-1]) * s1, -1.0, 1.0)
# 20-day trend (need enough history)
if INDEX_BET_TSMOM20 and len(mkt) >= 20:
trend20 = float(mkt[-20:].sum())
if abs(trend20) > 1e-15:
conf = np.clip(conf + np.sign(trend20) * INDEX_BET_TSMOM20, -1.0, 1.0)
return conf
def _orthog_xs(a, b):
"""Cross-sectional linear projection: a with the component collinear to b removed."""
b0 = b - b.mean()
a0 = a - a.mean()
denom = float(b0 @ b0) + 1e-12
beta = float(a0 @ b0) / denom
return a0 - beta * b0
def _conf_orthog_tsmom20(prc, ll_conf):
"""Per-name 20d log-price momentum, orthogonalised to lead-lag confidence."""
n, nt = prc.shape
look = ORTHOG_TSMOM20_LOOK
if nt < look + 2 or W_ORTHOG_TSMOM20 == 0.0:
return np.zeros(n)
mom = np.log(prc[:, -1]) - np.log(prc[:, -(look + 1)])
raw = _neutral_clip(np.clip(
(mom - mom.mean()) / (mom.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
o = _orthog_xs(raw, ll_conf)
return _neutral_clip(np.clip(o / (o.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
def _raw_tsmom_conf(prc, look):
"""Cross-sectionally standardised log-price momentum over `look` days."""
n, nt = prc.shape
if nt < look + 2:
return np.zeros(n)
mom = np.log(prc[:, -1]) - np.log(prc[:, -(look + 1)])
return _neutral_clip(np.clip(
(mom - mom.mean()) / (mom.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
def _conf_orthog_tsmom10(prc, ll_conf):
"""Per-name 10d momentum, residualised vs LL and raw 20d."""
n, nt = prc.shape
look = ORTHOG_TSMOM10_LOOK
if nt < ORTHOG_TSMOM20_LOOK + 2 or W_ORTHOG_TSMOM10 == 0.0:
return np.zeros(n)
raw10 = _raw_tsmom_conf(prc, look)
raw20 = _raw_tsmom_conf(prc, ORTHOG_TSMOM20_LOOK)
o = _orthog_xs(raw10, ll_conf)
o = _orthog_xs(o, raw20)
return _neutral_clip(np.clip(o / (o.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
def _conf_fade60(prc, ll_conf, orth20_conf):
"""Fade 60d per-name log return, residualised vs LL and orthog-20d."""
n, nt = prc.shape
look = FADE60_LOOK
if nt < look + 2 or W_FADE60 == 0.0:
return np.zeros(n)
mom = np.log(prc[:, -1]) - np.log(prc[:, -(look + 1)])
raw = -mom # fade
sig = _neutral_clip(np.clip(
(raw - raw.mean()) / (raw.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
o = _orthog_xs(sig, ll_conf)
o = _orthog_xs(o, orth20_conf)
return _neutral_clip(np.clip(o / (o.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
def _conf_fade_ma100(prc, ll_conf, orth20_conf, fade60_conf):
"""Fade price vs 100d MA, residualised vs LL, orthog-20d, and fade60."""
n, nt = prc.shape
ma = FADE_MA100
if nt < ma or W_FADE_MA100 == 0.0:
return np.zeros(n)
mav = prc[:, -ma:].mean(1)
raw = -(prc[:, -1] / (mav + 1e-12) - 1.0)
sig = _neutral_clip(np.clip(
(raw - raw.mean()) / (raw.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
o = _orthog_xs(sig, ll_conf)
o = _orthog_xs(o, orth20_conf)
o = _orthog_xs(o, fade60_conf)
return _neutral_clip(np.clip(o / (o.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
def _conf_err_revert(prc, orth20_conf):
"""Fade yesterday's lead-lag forecast error residualised vs r̂ and orthog-20d."""
n, nt = prc.shape
if nt < LL_MIN_HISTORY + 5 or W_ERR_REVERT == 0.0 or _LL_CACHE["B"] is None:
return np.zeros(n)
B = _LL_CACHE["B"]
r_tm1 = np.log(prc[:, -2]) - np.log(prc[:, -3]) # r(t-1)
r_tm2 = np.log(prc[:, -3]) - np.log(prc[:, -4]) # r(t-2)
e = r_tm1 - (r_tm2 @ B)
rhat = r_tm1 @ B
e_c = _neutral_clip(np.clip(
(e - e.mean()) / (e.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
rh_c = _neutral_clip(np.clip(
(rhat - rhat.mean()) / (rhat.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
o = _orthog_xs(e_c, rh_c)
o = _orthog_xs(o, orth20_conf)
return _neutral_clip(np.clip(
-o / (o.std() + 1e-12) / LL_FULL_Z, -1.0, 1.0))
def _ewma_vol(prc, span=INVVOL_SPAN):
"""Per-name EWMA volatility of log-returns (causal).
Vectorised closed form of the recursive filter (v0=0):
v_T = (1-λ) Σ_{k=0}^{T-1} λ^{T-1-k} r_k²
Same numerics as the old double Python loop; O(n·T) numpy vs O(n·T) py.
"""
n, nt = prc.shape
w = min(span * 3, nt - 1)
if w < 2:
return np.ones(n)
R = np.diff(np.log(prc[:, -(w + 1):]), axis=1) # n x T
lam = 1.0 - 2.0 / (span + 1.0)
Tlen = R.shape[1]
# weight on day k (oldest→newest): λ^{T-1}, λ^{T-2}, …, λ^0
wts = (1.0 - lam) * (lam ** np.arange(Tlen - 1, -1, -1))
return np.sqrt((R * R) @ wts) + 1e-12
def _apply_invvol(conf, prc):
"""Risk-weight conf by inverse EWMA vol, renormalised to median=1."""
if not INVVOL_ENABLE or INVVOL_POWER == 0.0:
return conf
vol = _ewma_vol(prc, INVVOL_SPAN)
inv = (1.0 / vol) ** INVVOL_POWER
w = inv / (np.median(inv) + 1e-12)
return np.clip(conf * w, -1.0, 1.0)
def getMyPosition(prcSoFar):
prc = np.asarray(prcSoFar, dtype=float)
n, nt = prc.shape
_maybe_rewind_caches(nt)
if nt < FACTOR_WIN + 2:
_ALGO_HYST_CACHE["shares"] = 0.0
_ALGO_HYST_CACHE["nt"] = nt
return np.zeros(n, dtype=int)
ll = _neutral_clip(_conf_leadlag(prc))
resid = _neutral_clip(_conf_resid(prc))
orth20 = _conf_orthog_tsmom20(prc, ll)
orth10 = _conf_orthog_tsmom10(prc, ll)
err = _conf_err_revert(prc, orth20)
fade60 = _conf_fade60(prc, ll, orth20)
fade_ma = _conf_fade_ma100(prc, ll, orth20, fade60)
conf = (W_LL * ll + W_RESID * resid
+ W_ORTHOG_TSMOM20 * orth20 + W_ORTHOG_TSMOM10 * orth10
+ W_ERR_REVERT * err + W_FADE60 * fade60 + W_FADE_MA100 * fade_ma)
conf = np.clip(conf - conf.mean(), -1.0, 1.0)
# Index tilt — EW mom 1d (asym long) + 20d (no re-demean)
conf = _apply_index_bet(conf, prc)
# Inverse-vol sizing on the full blended conf (pairs still additive after)
conf = _apply_invvol(conf, prc)
px = prc[:, -1]
max_shares = (_dollar_limits(n) * ALLOC / px).astype(int)
total = (conf * max_shares).astype(float)
# Additive ALGO cointegration-pairs overlay (grader clips to $ limits)
total = total + W_PAIRS * _pairs_positions(prc).astype(float)
# Additive broader (non-ALGO) cointegration-pairs overlay
total = total + _pairs2_positions(prc)
# Extra conviction on high-hit spreads when stretched
total = total + _hh_pairs_positions(prc)
# Finals-only luck bet: concordance floor on strongest historical ups
total = _apply_finals_trend_floor(total, prc)
# Damp noisy ALGO flips (deadband ~55% of $100k)
total = _apply_algo_hysteresis(total, px, nt)
return total.astype(int)