Repository navigation
Expand file tree
/
Copy pathalpha_loop.py
More file actions
4297 lines (3730 loc) · 229 KB
/
Copy pathalpha_loop.py
File metadata and controls
4297 lines (3730 loc) · 229 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
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
alpha_loop.py — autonomous generate -> test -> learn -> repeat loop for WorldQuant BRAIN,
now with optional autonomous submission of alphas that clear a quality bar.
Setup (once), same folder as brain_engine.py:
pip install requests python-dotenv google-genai pydantic
.env needs three lines:
WQ_EMAIL=you@example.com
WQ_PASSWORD=your-password
GEMINI_API_KEY=...
Run:
python alpha_loop.py # Gemini writes the ideas
python alpha_loop.py --try "<expression>" "<another>"
# test your own expressions through the same
# pipeline: simulate, check, submit if they qualify
python alpha_loop.py --log-dir logs # also keep the log in logs/alpha_loop_YYYY-MM-DD.log
# (what the daily scheduled run uses)
python alpha_loop.py --catch-up --log-dir logs
# for extra triggers (start-up, wifi back, every few
# hours): only runs while nothing is in for today
To start a run by hand, Start-ScheduledTask -TaskName "Alpha Miner" runs it in the background with
its log, the same as the scheduled run - it keeps going if you close the window.
Only one run at a time: a second run started while one is going exits straight away (alpha_loop.lock),
so a scheduled run and a manual one can never double-submit.
NOTE on the LLM library: this uses google-genai (import as `from google import genai`),
NOT google-generativeai (`import google.generativeai as genai`) - that package was
deprecated in August 2025 and is now archived/legacy. If you see import errors or silent
generation failures with the old package, that's why. Model defaults to gemini-3.8-flash
(current as of Sept 2026) - Google ships new Flash versions every few weeks, so this is
overridable via GEMINI_MODEL without touching code.
When Gemini is busy (503) or rate-limited (429), idea generation retries with backoff
(10s, 20s, 40s, 80s), then tries GEMINI_FALLBACK_MODEL once (default gemini-3.7-flash,
which Google's own 3.8 Flash docs name as the fully supported alternative), then - if NVIDIA_API_KEY is set in
.env (a free key from build.nvidia.com) - NVIDIA's Nemotron once (NEMOTRON_MODEL). A failed
generation does NOT use up one of the MAX_ROUNDS - rounds only count when ideas actually
get tested. After MAX_GENERATION_FAILURES failed attempts in a row, the run stops cleanly;
nothing was simulated in that case, so no BRAIN quota was spent.
READ BEFORE YOU RUN THIS:
- It spends real BRAIN simulation quota and real Gemini API cost, unattended, up to the
caps below (defaults: 5 rounds x 8 ideas = 40 simulations per run, plus follow-ups - and on a day
with nothing submitted yet, up to 10 extra rounds within the 4-hour cap). Raise
IDEAS_PER_ROUND / MAX_ROUNDS via env vars once you trust it, not before.
- This version can AUTONOMOUSLY SUBMIT alphas to BRAIN, at most MAX_SUBMISSIONS_PER_DAY
(default 1) per BRAIN day, which starts at 3 AM US Eastern, not local midnight. An alpha
qualifies when Sharpe >= 1.25, fitness >= 1.0 and every check passes, except SELF_CORRELATION
while it reads PENDING: BRAIN only works that one out during submission, so it shows PENDING
on every unsubmitted alpha. Before submitting, the loop asks BRAIN's free pre-submit
self-correlation check and skips anything at 0.7 or above, so the daily slot isn't wasted - and, except
for the end-of-day pick, holds anything at DAYTIME_MAX_SELF_CORRELATION (0.5) or above (see Near-copies).
After submitting, it waits for BRAIN's verdict and only logs "YES" once BRAIN lists the
alpha as ACTIVE. Anything unclear is logged as VERDICT_PENDING - confirm those with the
dashboard's Check with BRAIN button. The submit and correlation endpoints follow community
clients that recorded BRAIN's live traffic; BRAIN publishes no official API docs.
- Held alphas: one that clears the bar after today's submission is used is first run through BRAIN's
free self-correlation check - too similar to your submitted alphas, it's logged TOO_CORRELATED;
otherwise it's held as QUALIFIED_NOT_SUBMITTED. Every run (--try included) starts by re-checking
every held alpha, slot free or not, since similarity grows as you submit more: one BRAIN already
lists as submitted is marked YES, one where a check now FAILs is marked NOT_SUBMITTED, one that's
become too similar is marked TOO_CORRELATED. If today's slot is free, the best remaining one goes in,
as the rules below allow. If BRAIN can't be reached or the submit itself fails, the alpha stays
queued for the next run. Re-checking stops after HELD_CHECK_BUDGET_SECONDS (default 300) per run.
- Auto-flip: when an alpha scores Sharpe -1.0 or worse, the loop also tests reverse(<expression>)
right after it (up to MAX_FLIPS_PER_ROUND per round, default 3), since a strongly negative signal
usually turns positive when flipped. Flips use extra simulations.
- Auto-smooth: when an alpha has Sharpe >= 1.25 but fitness under 1.0 and turnover above
SMOOTH_MIN_TURNOVER (default 0.2) - or turnover over BRAIN's 70% limit with Sharpe >= 1.0
(SMOOTH_MIN_SHARPE_OVER_LIMIT) - the loop also tests
ts_decay_linear(<expression>, 10) right after it (up to MAX_SMOOTHS_PER_ROUND per round, default 3).
Fitness divides by turnover, so trading less can lift it over the bar. The same goes for a strong Sharpe
whose fitness misses the strong bar: with no failed check, turnover over SMOOTH_TOWARD_STRONG_MIN_TURNOVER
(0.15), and fitness that would reach STRONG_MIN_FITNESS at BRAIN's 12.5% turnover floor with the same returns.
- Auto-blend: an alpha with Sharpe >= 1.25 that isn't strong yet - it misses on fitness (usually a fast signal that
trades too much), or it clears the bar without reaching the strong one - is re-tested added to one of your best slow
alphas with a different idea: rank(<expression>) + rank(<slow>). Two unrelated signals added together trade about
half as much and usually beat both on Sharpe and fitness, and strong alphas weigh more in your combined score. A
slow partner is one of your results with Sharpe >= BLEND_PARTNER_MIN_SHARPE (1.0), every check passed except
similarity and the ones its own Sharpe or fitness explain, the big-stocks check passed on BRAIN's numbers, turnover
under BLEND_PARTNER_MAX_TURNOVER (15%), no data field in common with the alpha and either other data or - on the same
data - one tracking a level and the other a change (earnings yield and cash-flow surprise are both analyst data but
different ideas), using datasets on
your field list; the best Sharpe goes first - alphas outside your submitted pool before ones in it or too similar to
it - one per kind of data, never a blend. Up to MAX_BLENDS_PER_ALPHA (default
2) blends per alpha and MAX_BLENDS_PER_ROUND (default 4) per round, after the free similarity check: an original
that's already too similar to your submitted alphas - or held as somewhat similar - isn't blended. A held alpha
stays held while its blends are tested. An alpha that failed the big-stocks check isn't blended either - a partner
doesn't make a small-stocks idea work in big ones. (This replaced the old auto-repair: its 307 tests on your account
- finer groups, size buckets, the liquid/illiquid split, rank or ts_backfill - never produced an alpha that passed
every check, and repairs of repairs grew past BRAIN's 64-operator limit.)
A strong alpha failing a check still gets a log line naming it, with BRAIN's value and limit when BRAIN gives them.
- Auto-sharpen: an alpha with Sharpe >= 1.25 but fitness under 1.0 whose turnover is already under 12.5%
- where BRAIN's fitness formula stops giving credit for trading less - is re-tested without its outer
ts_decay_linear/hump (up to MAX_SHARPENS_PER_ROUND, default 3), after the same free similarity check.
- Each normal run starts with earlier alphas worth a blend: up to MAX_PAST_BLENDS (default 3) that missed on fitness
or are held without being strong (with BRAIN's big-stocks numbers on file and passing) get the blends not tried yet, best Sharpe first, and up to
MAX_PAST_SHARPENS (default 3) slow ones get sharpened.
- Auto-pair: an alpha with Sharpe >= PAIR_MIN_SHARPE (1.5) that fails only the big-stocks check (besides fitness or
turnover) is re-tested added to up to MAX_PAIRS_PER_ALPHA (2) of your alphas on other data that hold up best among
the biggest stocks (their Sharpe there at least PAIR_PARTNER_MIN_BIG_RATIO, 1.2, times BRAIN's limit; one per
dataset). Up to MAX_PAIRS_PER_ROUND (4) a round, and MAX_PAST_PAIRS (3) earlier ones at the start of each run. Option
and news signals often work only in small stocks; paired like this, many pass.
- Auto-combine: every round also tests COMBINES_PER_ROUND (4) blends of two or three of your own good single signals
(Sharpe COMBINE_MIN_SHARPE, 1.4, or more; the best COMBINE_POOL_SIZE, 12), each on a different dataset and none on
data your submitted alphas cover, at least one passing the big-stocks check by itself - the hand-made batches that
passed best were built this way. Best expected Sharpe first, less a little for data already in your portfolio;
no two in a round share a signal.
- Operator limit: BRAIN refuses an expression with more than 64 operators (every function call and every
+ - * / or comparison counts). Longer ideas are skipped before they're sent.
- Never miss a day (the Gold leaderboard counts the days you submitted on): a normal run that still has
nothing submitted for today after MAX_ROUNDS keeps going - up to MAX_EXTRA_ROUNDS (default 10) more rounds,
within the MAX_RUNTIME_SECONDS cap - and stops as soon as one lands. Before each extra round the held
queue gets another try. A submission only counts toward the day once BRAIN takes it: if BRAIN rejects
one, the run goes on to the next good alpha. If a submit doesn't go through at all (connection dropped,
BRAIN throttling), the alpha is held and tried again rather than lost.
- Reserve: today's first submission always goes in. Any more, up to MAX_SUBMISSIONS_PER_DAY, only when at
least RESERVE_TARGET (default 3) other kinds of good alpha would still be waiting for the coming days.
An alpha's kind is the BRAIN dataset its data fields come from (data_fields_cache.json; without it, the
field name minus its numbers): alphas on the same data usually come back too similar to submit both, so
near-copies count once. Only alphas BRAIN confirmed during this run count. RESERVE_TARGET=0 turns it off.
Once your combined portfolio can be read (Portfolio check), the reserve counts every good alpha in your held
queue instead, and every submission beyond the day's first must raise the combined Sharpe by EXTRA_MIN_GAIN
(default 0.01 = 1%) - strong or not, since what it adds is what the IS Score rates. Near-copies can't, so they wait for a coming day instead
(as "daily cap reached"). If BRAIN turns a submission away without naming a failed check (a limit on submissions,
say), the alpha stays queued and nothing more is submitted until BRAIN's next day.
- Strong alphas first: an alpha that clears BRAIN's bar counts as strong with Sharpe >= STRONG_MIN_SHARPE (1.6),
fitness >= STRONG_MIN_FITNESS (1.3) and annual returns above its worst drawdown (STRONG_MIN_RETURNS_TO_DRAWDOWN,
1.0; 0 turns that part off). A strong alpha goes in straight away; one that only clears BRAIN's bar is held
("not strong"). Held alphas are ranked strong first, then by what each would add to your combined Sharpe (see
Portfolio check) - or, when that can't be read, by fitness on BRAIN's latest numbers. If nothing
has gone in by the end of the usual MAX_ROUNDS rounds - or the run stops early, or a --try run ends, or BRAIN's
day is about to end - the run settles: an end-of-day pick from the held alphas that clear BRAIN's bar (see
Portfolio check for how it's chosen) goes in, so the day can still count. Submissions beyond the day's first
must be strong - unless your combined portfolio can be read, when what they add decides (see Reserve). Returns and
drawdown are saved in the results file (an older file gets the two columns added
once) and shown to the generator, which is told the strong targets.
- Near-copies: BRAIN accepts self-correlation up to 0.7, but every submission except the end-of-day pick must be
under DAYTIME_MAX_SELF_CORRELATION (default 0.5) - a strong alpha straight away, an extra one, a held one.
Near-copies of your own alphas add little to a combined score and keep your submissions looking alike. One
in between - or one whose similarity BRAIN didn't report in time - is held instead, labelled "somewhat
similar", and can still be the end-of-day pick. Once your combined portfolio can be read, one in between that
raises the combined Sharpe by EXTRA_MIN_GAIN or more goes in anyway: it plainly isn't just a copy.
DAYTIME_MAX_SELF_CORRELATION=0.7 turns it off.
- Portfolio check: the Country Challenge's IS Score is "Combined Portfolio Performance based on an equal weighting
of all submitted alphas". So before any alpha goes in, the loop reads the daily PnL of your submitted alphas
(BRAIN's list of your ACTIVE alphas - or, failing that, the results file - and the PnL behind each alpha's
chart), adds the new alpha's with equal weights, and lets it in only if the combined Sharpe rises. One that would
lower it is held, labelled "would lower your combined portfolio", left out of the reserve and checked again each
run. The end-of-day pick takes the held alpha that leaves the combined Sharpe highest (least similar breaks ties)
and must raise it too - unless END_OF_DAY_MAX_PORTFOLIO_DROP (default 0) allows a small drop to keep the day's
streak: 0.02 = up to 2%. If the PnL can't be read, the usual rules decide alone. PORTFOLIO_CHECK=0 turns it off.
The held queue is ranked the same way: each run scores every held alpha by what it would add to the combined
Sharpe, re-checks them with BRAIN biggest gain first (so with a long queue the best are the ones checked), and
submits the strong one with the biggest gain - not the one with the best fitness - re-ranking the rest after each
one goes in. Each alpha's PnL is read from BRAIN once and kept in pnl_cache/ (it never changes), so this is quick;
reading new ones stops after PNL_PREFETCH_SECONDS (300) per run and the rest are scored next run.
- Already covered: an ingredient of an alpha is the family of a data field one of its signals builds on - the
field without its numbers, provider and version words or trailing summary, so every window of
unsystematic_risk_last_N_days is one family, and so is every summary of analysts' EBIT forecasts (anl4_ebit_value,
..._ebit_high, ..._ebit_low) - or, for a signal built on price and volume alone, which of those it uses (close and
vwap for an intraday reversal). A blend's signals count separately, smoothed or not. One in SATURATED_AT (default
3) or more of your submitted alphas is covered: Gemini is told so, its ideas built only on covered data are
skipped, they're left out of the best results it learns from and of the examples in the data scorecard, nothing
built only on covered data is blended, and no partner uses it. The IS Score rates your alphas combined, so a
near-copy of what's in adds almost nothing. SATURATED_AT=0 turns it off.
- --backup, for a second scheduled run later in the day: if today's submission is already in and the
reserve is full, it stops after re-checking the held queue; otherwise it mines like a normal run.
- Before/after check: before each submission, and for each alpha it holds, the loop asks BRAIN's free
"before and after" check - how your merged performance would change with that alpha - for the competition in
BRAIN_COMPETITION, or the only one BRAIN lists for you (each run logs your competitions, with your standing when
BRAIN gives it), else for your account. BRAIN publishes no format for the reply, so it's only recorded - a
one-line summary in the log, the full reply in logs/before_after_checks.jsonl - and decides nothing.
- Learning what doesn't work: any operator or field BRAIN rejects ('unknown variable/operator') is
read back from the results file, removed from what the generator sees, and ideas still using it are
dropped before they cost a simulation. Fields tried DEAD_END_MIN_TRIES (default 8) times without
fitness reaching DEAD_END_MAX_FITNESS (default 0.5) are left out the same way - not counting results that failed
the big-stocks check, so data whose only good scores come from small stocks is left out too; basic price/volume
fields (close, volume, returns...) are exempt. Ideas using a data field BRAIN offers you that your field list
(operators.json) leaves out are skipped too - usually old fields the generator remembers from your best past
results; it needs data_fields_cache.json (from fetch_fields.py) to tell fields from other words.
- Variety: alphas built on the same kind of data usually come back too correlated to submit. Each
field is labelled with its data type - BRAIN's own category from data_fields_cache.json (written by
fetch_fields.py), or the dataset name in operators.json without it. The generator is told which types
your submitted or queued alphas already use, and asked for VARIETY_EXTRA_IDEAS (default 6) spare
ideas; the loop then keeps at most MAX_IDEAS_PER_TYPE (default 2) per type per round, and at most
MAX_IDEAS_PER_CROWDED_TYPE (default 1) for types you've already submitted.
- Formula shape: each expression is reduced to its skeleton (smoothing wrapped around the outside,
fields, numbers and groups stripped, any ts_*/group_* operator treated alike). Shapes your submitted, queued or too-similar alphas use - or that
make up 30%+ of your last 60 simulations - count as overused: the generator is shown them with a list of
other structures from your operator list, and the loop keeps at most MAX_IDEAS_PER_CROWDED_SHAPE
(default 2) ideas per overused shape per round.
- Parallel simulations: up to PARALLEL_SIMULATIONS (default 3) run at once. If BRAIN answers that too
many are running, the loop carries on with however many it already has going. Results are still
handled one at a time, so submissions never overlap. Set PARALLEL_SIMULATIONS=1 for the old pace.
- Network resilience: every request to BRAIN has a 30-second timeout and is retried after a dropped
connection (waiting 5s, 15s, 45s), and each Gemini request has a GEMINI_TIMEOUT_SECONDS (300) limit - the
library's own default is none, so a dropped connection could freeze a run for hours. When something still
can't get through, the loop asks whether BRAIN answers at all. If it does, that one idea is logged as
"Couldn't reach BRAIN" and the loop moves on (after MAX_NETWORK_FAILURES, default 3, in a row it stops
cleanly). If BRAIN doesn't answer, the connection itself is gone - wifi dropped, a laptop just woke up - so
the run waits: it checks every minute for up to WAIT_FOR_CONNECTION_MINUTES (120) and then carries on where
it left off, simulations already running on BRAIN included. Logging in waits the same way, and a login
that failed only because the connection was down is no longer reported as identity verification. Time
spent waiting, or with the computer asleep, doesn't count against MAX_RUNTIME_SECONDS.
- Laptops: while a run is going and the computer is plugged in, it asks Windows not to sleep (the screen can
still turn off). On battery it lets the computer sleep as usual and carries on when it wakes.
- --catch-up, for extra scheduled triggers (start-up, reconnecting to the internet, every few hours): exits
straight away if today's submission is already in, or if another run is going; otherwise it runs like a
normal run and stops as soon as today's submission lands.
- End of BRAIN's day: a run with nothing in yet SETTLE_BEFORE_DAY_END_MINUTES (60) before BRAIN's day ends
settles straight away - the end-of-day pick goes in - instead of waiting for a strong one it may not have
time to find.
- Big-stocks check: BRAIN's numbers for it (the alpha's Sharpe among the largest stocks, and the limit there) are
saved in the results file (sub_universe_sharpe, sub_universe_limit). The generator is told which ideas failed it - one example per idea, with every
version of it (other windows, maturities, groups, smoothing or fixes) counting as the same idea - and asked to
stop writing new versions of them, and those results are marked in the "best results" list it learns from.
- Padding: ideas like divide(x, add(y, 0.0001)) - a tiny number added so a division can't hit zero - are
tested without it. Every alpha with it came back with an extra check that didn't PASS (BRAIN's units check:
unitHandling is VERIFY, and a plain number has no units), and the loop only submits alphas whose every check
passes. Follow-ups and blends are built on the expression without it, and two expressions that differ only
by padding count as the same idea when deciding what was already tried.
- Memory across runs: every round, the generator sees your best MEMORY_TOP_N (default 10) results
from all runs so far (read from alpha_loop_results.csv), not just the last round. Results that failed the
big-stocks check aren't among them - they're listed separately as ideas not to rework.
- What works: the generator is also shown which BRAIN datasets have produced alphas that passed every check on
your account, out of how many tests, which never have after DATA_SCORECARD_MIN_TESTS (10) tests, and the
recipes behind your best alphas: size groups, change measured against its usual size, 15-20 day smoothing,
fast-plus-slow blends.
- operators.json holds the full base-tier operator list (66 operators) transcribed from
your BRAIN account. Its "fields" list is still the small 8-field starter set - add the
data fields your account can use to widen what the generator can propose.
- "Unattended" has a real ceiling: BRAIN sessions expire (roughly 4 hours) and can
require a one-time identity check to renew. This script saves its session to disk
so a re-run doesn't need to log in again unnecessarily, but if BRAIN asks for
verification mid-run with nobody there to click the link, it STOPS CLEANLY and
logs why. It does not hang waiting for input that will never come.
"""
import argparse
import csv
import itertools
import json
import logging
import math
import os
import re
import statistics
import time
from collections import Counter, deque, namedtuple
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import httpx # the HTTP library google-genai uses; its network errors are worth retrying
import requests
from google import genai
from google.genai import types
from pydantic import BaseModel
import brain_engine as be # reuses: get_credentials, authenticate, submit_simulation,
# poll_simulation, fetch_alpha_metrics, looks_like_expired_session
logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO)
logger = logging.getLogger(__name__)
# ---- Guardrails: real quota and real money per round. Keep these low until you trust it. ----
IDEAS_PER_ROUND = int(os.environ.get("IDEAS_PER_ROUND", 8))
MAX_ROUNDS = int(os.environ.get("MAX_ROUNDS", 5))
MAX_EXTRA_ROUNDS = int(os.environ.get("MAX_EXTRA_ROUNDS", 10)) # past MAX_ROUNDS, only while nothing is in for today
MAX_RUNTIME_SECONDS = int(os.environ.get("MAX_RUNTIME_SECONDS", 4 * 60 * 60)) # ~ BRAIN's own session lifetime
MAX_SUBMISSIONS_PER_DAY = int(os.environ.get("MAX_SUBMISSIONS_PER_DAY", 1)) # BRAIN's own submitter tools cap at 1/day
RESERVE_TARGET = int(os.environ.get("RESERVE_TARGET", 3)) # kinds of good alpha kept back before a 2nd one a day
# Strong alphas go in first; one that only clears BRAIN's bar waits until the usual rounds are done.
STRONG_MIN_SHARPE = float(os.environ.get("STRONG_MIN_SHARPE", 1.6))
STRONG_MIN_FITNESS = float(os.environ.get("STRONG_MIN_FITNESS", 1.3))
STRONG_MIN_RETURNS_TO_DRAWDOWN = float(os.environ.get("STRONG_MIN_RETURNS_TO_DRAWDOWN", 1.0)) # 0 turns this part off
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.8-flash")
# Google's own Gemini 3.8 Flash docs name 3.7 Flash as the fully supported alternative.
# Set GEMINI_FALLBACK_MODEL to an empty value in .env to turn the fallback off.
GEMINI_FALLBACK_MODEL = os.environ.get("GEMINI_FALLBACK_MODEL", "gemini-3.7-flash")
RETRY_DELAYS_SECONDS = (10, 20, 40, 80) # backoff when the main model is busy or rate-limited
TRANSIENT_STATUS_CODES = {429, 500, 502, 503, 504} # worth waiting out; anything else won't fix itself
MAX_GENERATION_FAILURES = int(os.environ.get("MAX_GENERATION_FAILURES", 3)) # in a row, then stop the run
GENERATION_COOLDOWN_SECONDS = int(os.environ.get("GENERATION_COOLDOWN_SECONDS", 60)) # pause between failed attempts
# A time limit on each Gemini request. The library's default is none at all, so a connection that drops
# mid-request could leave a run waiting for hours - and while it waits, Windows skips the next scheduled run.
GEMINI_TIMEOUT_SECONDS = int(os.environ.get("GEMINI_TIMEOUT_SECONDS", 300))
# Backup idea writer: NVIDIA's Nemotron, asked only when Gemini (main model and fallback) can't answer a round. Needs a
# free key from build.nvidia.com as NVIDIA_API_KEY in .env; without one there's no backup. NEMOTRON_MODEL takes any
# model id from NVIDIA's catalog.
NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "").strip()
NEMOTRON_MODEL = os.environ.get("NEMOTRON_MODEL", "nvidia/nemotron-3.5-super-vl-120b-a12b")
NEMOTRON_URL = os.environ.get("NEMOTRON_URL", "https://integrate.api.nvidia.com/v1/chat/completions")
NEMOTRON_TIMEOUT_SECONDS = int(os.environ.get("NEMOTRON_TIMEOUT_SECONDS", 300))
# Submission bar - unchanged values, just named once so dashboard.py reads the same numbers.
SUBMIT_MIN_SHARPE = 1.25
SUBMIT_MIN_FITNESS = 1.0
# BRAIN's accepted turnover range: 1% to 70% of the book traded per day (reported as 0.01 to 0.70).
TURNOVER_MIN, TURNOVER_MAX = 0.01, 0.70
MAX_SELF_CORRELATION = 0.7 # BRAIN rejects a submission this correlated with your already-submitted alphas
# Stricter than BRAIN: every submission except the end-of-day pick (the one that keeps the day's streak) must be
# under this. Near-copies of your own alphas add little to a combined score and keep your alphas looking alike.
# One between this and 0.7 is held - it can still be an end-of-day pick, least similar first. 0.7 turns it off.
DAYTIME_MAX_SELF_CORRELATION = float(os.environ.get("DAYTIME_MAX_SELF_CORRELATION", 0.5))
# How long to wait for BRAIN's similarity check. A fresh alpha's can take BRAIN many minutes; one not read in time is
# held and read again later (next run's held-queue check), rather than the whole run waiting on it.
CORR_POLL_MAX_SECONDS = int(os.environ.get("CORR_POLL_MAX_SECONDS", 60))
SUBMIT_POLL_MAX_SECONDS = int(os.environ.get("SUBMIT_POLL_MAX_SECONDS", 600))
CORRELATION_URL = "https://api.worldquantbrain.com/alphas/{alpha_id}/correlations/self"
SUBMIT_URL = "https://api.worldquantbrain.com/alphas/{alpha_id}/submit"
# BRAIN's free 'before and after' check: how your merged performance would change with one more alpha.
# The address follows WorldQuant's own ACE library; the reply's layout isn't published, so it's only recorded.
BEFORE_AFTER_URL = "https://api.worldquantbrain.com/users/self/alphas/{alpha_id}/before-and-after-performance"
BEFORE_AFTER_FILE = os.path.join("logs", "before_after_checks.jsonl")
BEFORE_AFTER_MAX_SECONDS = int(os.environ.get("BEFORE_AFTER_MAX_SECONDS", 120))
# The same check for one competition (the Country Challenge is part of one): BRAIN lists yours at COMPETITIONS_URL.
# The run asks it for the competition in BRAIN_COMPETITION, or the only one you're in, and only records the answer.
COMPETITIONS_URL = "https://api.worldquantbrain.com/users/self/competitions"
COMPETITION_BEFORE_AFTER_URL = ("https://api.worldquantbrain.com/competitions/{competition}/alphas/{alpha_id}"
"/before-and-after-performance")
BRAIN_COMPETITION = os.environ.get("BRAIN_COMPETITION", "").strip()
# Portfolio check. The leaderboard's IS Score is "Combined Portfolio Performance based on an equal weighting of all
# submitted alphas", so before an alpha goes in, its daily PnL is added to your submitted alphas' (equal weights) and
# it goes in only if the combined Sharpe rises. PORTFOLIO_CHECK=0 turns this off.
PORTFOLIO_CHECK = os.environ.get("PORTFOLIO_CHECK", "1").strip().lower() not in ("0", "false", "no", "off")
# How far the end-of-day pick - the one that keeps the day's streak - may lower that combined Sharpe: 0.02 = by up to
# 2%. At 0 it must raise it like any other, or the day goes without a submission.
END_OF_DAY_MAX_PORTFOLIO_DROP = float(os.environ.get("END_OF_DAY_MAX_PORTFOLIO_DROP", 0.0))
USER_ALPHAS_URL = "https://api.worldquantbrain.com/users/self/alphas"
PNL_URL = "https://api.worldquantbrain.com/alphas/{alpha_id}/recordsets/pnl"
PNL_MAX_SECONDS = 120 # per alpha, while BRAIN says its PnL is still being prepared
# An alpha's in-sample PnL never changes once it's simulated, so each one read is kept in this folder (one small file
# per alpha) and never asked for again - that makes ranking a long held queue by portfolio gain quick.
PNL_CACHE_DIR = "pnl_cache"
PNL_PREFETCH_SECONDS = int(os.environ.get("PNL_PREFETCH_SECONDS", 300)) # per run, reading held alphas' PnL
# Submissions beyond the day's first must raise your combined Sharpe by at least this much (0.01 = 1%).
EXTRA_MIN_GAIN = float(os.environ.get("EXTRA_MIN_GAIN", 0.01))
PORTFOLIO_MIN_DAYS = 250 # trading days all the alphas must share for a fair comparison (about a year)
ALPHA_LIST_PAGE = 100
MAX_ALPHA_LIST_PAGES = 30
# Auto-flip: a strongly negative Sharpe is a real signal pointing the wrong way, so test its reverse.
FLIP_SHARPE_AT_OR_BELOW = float(os.environ.get("FLIP_SHARPE_AT_OR_BELOW", -1.0))
MAX_FLIPS_PER_ROUND = int(os.environ.get("MAX_FLIPS_PER_ROUND", 3)) # caps the extra simulations flips add
# Auto-smooth: a good Sharpe held back by trading too much gets re-tested with linear decay.
SMOOTH_DECAY_DAYS = int(os.environ.get("SMOOTH_DECAY_DAYS", 10))
SMOOTH_MIN_TURNOVER = float(os.environ.get("SMOOTH_MIN_TURNOVER", 0.2)) # below this, decay can barely help fitness
SMOOTH_MIN_SHARPE_OVER_LIMIT = float(os.environ.get("SMOOTH_MIN_SHARPE_OVER_LIMIT", 1.0)) # turnover over 70%
MAX_SMOOTHS_PER_ROUND = int(os.environ.get("MAX_SMOOTHS_PER_ROUND", 3)) # caps the extra simulations smoothing adds
# ...and a strong Sharpe whose fitness misses the strong bar is smoothed too, when it trades more than this and
# trading at BRAIN's 12.5% fitness floor (same returns) would put fitness over STRONG_MIN_FITNESS.
SMOOTH_TOWARD_STRONG_MIN_TURNOVER = float(os.environ.get("SMOOTH_TOWARD_STRONG_MIN_TURNOVER", 0.15))
# Padding like add(x, 0.0001) - a tiny number added so a division can't hit zero - changes nothing that matters,
# but every alpha that had it came back with an extra check that didn't PASS (most likely BRAIN's units check:
# unitHandling is VERIFY, and a plain number has no units). Gemini's ideas are stripped of numbers up to this size.
PADDING_MAX = 0.001
# Network resilience: one dropped connection skips that idea; this many in a row stops the run cleanly.
MAX_NETWORK_FAILURES = int(os.environ.get("MAX_NETWORK_FAILURES", 3))
# ...unless the connection itself is gone (wifi dropped, laptop just woke up): then the run checks every
# CONNECTION_CHECK_SECONDS and carries on where it left off once BRAIN answers again, for up to
# WAIT_FOR_CONNECTION_MINUTES, and only then stops. Time spent waiting doesn't count against MAX_RUNTIME_SECONDS.
WAIT_FOR_CONNECTION_MINUTES = int(os.environ.get("WAIT_FOR_CONNECTION_MINUTES", 120))
CONNECTION_CHECK_SECONDS = 60
CONNECTION_PROBE_TIMEOUT = 15
SLEEP_GAP_SECONDS = 120 # a pause that ran this much over was the computer asleep (lid closed, Windows sleep)
# A run with nothing in for today this close to the end of BRAIN's day settles straight away (the end-of-day pick
# goes in) instead of waiting for a strong one it may not have time to find.
SETTLE_BEFORE_DAY_END_MINUTES = int(os.environ.get("SETTLE_BEFORE_DAY_END_MINUTES", 60))
# Auto-blend: a good Sharpe held back only by fitness - usually a fast signal that trades too much - is re-tested
# added to one of your best slow alphas on other data, rank(alpha) + rank(slow). Each blend is one extra simulation.
MAX_BLENDS_PER_ALPHA = int(os.environ.get("MAX_BLENDS_PER_ALPHA", 2))
MAX_BLENDS_PER_ROUND = int(os.environ.get("MAX_BLENDS_PER_ROUND", 4))
MAX_PAST_BLENDS = int(os.environ.get("MAX_PAST_BLENDS", 3)) # earlier near-misses blended at the start of each run
MAX_PAST_SHARPENS = int(os.environ.get("MAX_PAST_SHARPENS", 3)) # ...and earlier slow ones sharpened
BLEND_PARTNER_MAX_TURNOVER = float(os.environ.get("BLEND_PARTNER_MAX_TURNOVER", 0.15)) # what counts as slow
BLEND_PARTNER_MIN_SHARPE = float(os.environ.get("BLEND_PARTNER_MIN_SHARPE", 1.0))
BLEND_FIXES = {"LOW_FITNESS", "HIGH_TURNOVER"} # failed checks a blend can fix; any other failure rules it out
# Auto-pair: an alpha with this Sharpe that only works in small stocks (it fails the big-stocks check) is added to
# your alphas on other data that hold up best among the biggest stocks - their Sharpe there at least
# PAIR_PARTNER_MIN_BIG_RATIO times BRAIN's limit. Up to MAX_PAIRS_PER_ALPHA partners each, MAX_PAIRS_PER_ROUND per
# round, and MAX_PAST_PAIRS earlier ones at the start of each run. MAX_PAIRS_PER_ROUND=0 and MAX_PAST_PAIRS=0 turn it off.
PAIR_MIN_SHARPE = float(os.environ.get("PAIR_MIN_SHARPE", 1.5))
PAIR_PARTNER_MIN_BIG_RATIO = float(os.environ.get("PAIR_PARTNER_MIN_BIG_RATIO", 1.2))
MAX_PAIRS_PER_ALPHA = int(os.environ.get("MAX_PAIRS_PER_ALPHA", 2))
MAX_PAIRS_PER_ROUND = int(os.environ.get("MAX_PAIRS_PER_ROUND", 4))
MAX_PAST_PAIRS = int(os.environ.get("MAX_PAST_PAIRS", 3))
PAIR_FIXES = BLEND_FIXES | {"LOW_SUB_UNIVERSE_SHARPE"}
# Auto-combine: each round also tests COMBINES_PER_ROUND blends of two or three of your own good single signals, each on a
# different dataset and none on data your submitted alphas cover - rank(a) + rank(b) [+ rank(c)]. Components need Sharpe
# COMBINE_MIN_SHARPE+; the best COMBINE_POOL_SIZE are combined. COMBINES_PER_ROUND=0 turns it off.
COMBINES_PER_ROUND = int(os.environ.get("COMBINES_PER_ROUND", 4))
COMBINE_MIN_SHARPE = float(os.environ.get("COMBINE_MIN_SHARPE", 1.4))
COMBINE_POOL_SIZE = int(os.environ.get("COMBINE_POOL_SIZE", 12))
AVOID_SHOWN = 60 # already-tried expressions shown to the generator each round, most recent first # what pairing can fix: the big-stocks check, and what a blend can
# BRAIN refuses an expression with more operators than this: every function call and every + - * / or comparison.
MAX_OPERATORS = 64
SMALL_STOCKS_EXAMPLES = 8 # ideas that failed the big-stocks check, shown to the generator as ones not to rework
# Auto-sharpen: below this turnover BRAIN's fitness formula gets no extra credit, so smoothing an alpha
# that's already this slow only dampens its returns.
FITNESS_TURNOVER_FLOOR = 0.125
MAX_SHARPENS_PER_ROUND = int(os.environ.get("MAX_SHARPENS_PER_ROUND", 3))
# Learning what doesn't work: names BRAIN rejected, and fields that never got close.
DEAD_END_MIN_TRIES = int(os.environ.get("DEAD_END_MIN_TRIES", 8))
DEAD_END_MAX_FITNESS = float(os.environ.get("DEAD_END_MAX_FITNESS", 0.5))
# What works: datasets with at least this many tests are scored for the generator (alphas passing every check).
DATA_SCORECARD_MIN_TESTS = int(os.environ.get("DATA_SCORECARD_MIN_TESTS", 10))
# Already covered: data your submitted alphas lean on - an ingredient in this many of them or more. New alphas built
# only on covered data add little to your combined score, so they aren't tested or blended. 0 turns this off.
SATURATED_AT = int(os.environ.get("SATURATED_AT", 3))
BASIC_FIELDS = ("open", "high", "low", "close", "vwap", "volume", "returns", "adv20", "cap") # never dropped
# Variety: alphas built on the same kind of data tend to be too correlated to submit together.
FIELDS_CACHE_FILE = "data_fields_cache.json" # written by fetch_fields.py - holds BRAIN's category per field
MAX_IDEAS_PER_TYPE = int(os.environ.get("MAX_IDEAS_PER_TYPE", 2)) # per data type, per round
MAX_IDEAS_PER_CROWDED_TYPE = int(os.environ.get("MAX_IDEAS_PER_CROWDED_TYPE", 1)) # types you've already submitted
VARIETY_EXTRA_IDEAS = int(os.environ.get("VARIETY_EXTRA_IDEAS", 6)) # ask for spares, keep the most varied
# Formula shape: the operators nested the same way, whatever the fields, windows or groups.
MAX_IDEAS_PER_CROWDED_SHAPE = int(os.environ.get("MAX_IDEAS_PER_CROWDED_SHAPE", 2)) # per round
SHAPE_LOOKBACK = 60 # recent simulations looked at to spot an overused shape
SHAPE_CROWDED_SHARE = 0.3 # a shape making up this share of them counts as overused
# Parallel simulations: BRAIN runs a few of yours at once. If it says it's busy (429), the loop
# drops to however many were already running and keeps going.
PARALLEL_SIMULATIONS = max(1, int(os.environ.get("PARALLEL_SIMULATIONS", 3)))
SIM_CHECK_SECONDS = 5 # how often running simulations are checked
BUSY_WAIT_SECONDS = 15 # BRAIN busy and nothing of ours running: wait this long, then retry
MAX_BUSY_WAIT_SECONDS = 10 * 60 # ...for at most this long before giving up on that idea
# Memory across runs: how many of your best past results the generator sees each round.
MEMORY_TOP_N = int(os.environ.get("MEMORY_TOP_N", 10))
SETTINGS = {
"instrumentType": "EQUITY", "region": "USA", "universe": "TOP3000",
"delay": 1, "decay": 0, "neutralization": "SUBINDUSTRY", "truncation": 0.08,
"pasteurization": "ON", "unitHandling": "VERIFY", "nanHandling": "ON",
"language": "FASTEXPR", "visualization": False,
}
OPERATORS_FILE = "operators.json"
RESULTS_FILE = "alpha_loop_results.csv" # separate from brain_engine.py's own results.csv
SESSION_FILE = "session_cookies.json"
SESSION_MIN_SECONDS = 30 * 60 # reuse a saved BRAIN login only if it has at least this long left
SESSION_RENEW_SECONDS = 5 * 60 # during a run, log in again this long before the login runs out
SUBMISSION_LOG_FILE = "submissions_log.json" # {"YYYY-MM-DD": submissions BRAIN took that day} - shared by every run
HELD_PREFIX = "QUALIFIED_NOT_SUBMITTED"
HELD_LABEL = f"{HELD_PREFIX} (daily cap reached)" # cleared the bar, waiting for a free submission slot
HELD_RETRY_LABEL = f"{HELD_PREFIX} (submit didn't go through - tried again next run)"
HELD_FOR_STRONGER_LABEL = f"{HELD_PREFIX} (not strong - kept in case a stronger one turns up)"
HELD_SIMILAR_LABEL = f"{HELD_PREFIX} (somewhat similar to your submitted alphas - kept for an end-of-day pick)"
HELD_PORTFOLIO_LABEL = f"{HELD_PREFIX} (would lower your combined portfolio - kept in case that changes)"
REPLACE_ATTEMPTS = 5 # Windows blocks swapping in the updated CSV while another program is reading it
HELD_CHECK_BUDGET_SECONDS = int(os.environ.get("HELD_CHECK_BUDGET_SECONDS", 300)) # per run, for re-checking held alphas
RESULTS_HEADER = [
"round", "timestamp", "expression", "rationale", "status", "sharpe", "fitness",
"turnover", "checks_passed", "checks_total", "alpha_id", "alpha_link", "error", "submitted",
"returns", "drawdown", "sub_universe_sharpe", "sub_universe_limit",
]
class StopLoop(Exception):
"""Raised to unwind out of a round immediately - e.g. session can't be renewed unattended."""
class LoginFailed(RuntimeError):
"""Couldn't log in to BRAIN unattended (wrong password, or BRAIN wants identity verification)."""
class GenerationFailed(Exception):
"""Raised when no ideas could be generated this attempt (main model and fallback both
failed). The main loop catches it and does NOT count the attempt as a round."""
# ---- Gemini structured-output schema (Pydantic - this is what the current SDK's docs use;
# the old TypedDict-based approach was tied to the deprecated library) ----
class Idea(BaseModel):
expression: str
rationale: str
class AlphaIdeas(BaseModel):
ideas: list[Idea]
def load_reference():
with open(OPERATORS_FILE, encoding="utf-8") as f:
ref = json.load(f)
return ref["operators"], ref["fields"]
def save_session(session: requests.Session):
Path(SESSION_FILE).write_text(
json.dumps(requests.utils.dict_from_cookiejar(session.cookies)), encoding="utf-8"
)
def load_session(session: requests.Session) -> bool:
if not Path(SESSION_FILE).exists():
return False
try:
cookies = json.loads(Path(SESSION_FILE).read_text(encoding="utf-8"))
session.cookies.update(requests.utils.cookiejar_from_dict(cookies))
return True
except Exception:
return False
def session_seconds_left(session):
"""How long BRAIN says this login has left: GET /authentication -> token.expiry, the same check
WorldQuant's own ACE library uses. 0 if BRAIN says it isn't logged in, None if it can't tell.
(Asking about an alpha can't tell: logged out, BRAIN may answer 'not found' instead.)"""
try:
resp = session.get(be.AUTH_URL, timeout=be.REQUEST_TIMEOUT)
except requests.RequestException:
return None
if resp.status_code in (401, 403) or be.looks_like_expired_session(resp):
return 0.0
if resp.status_code != 200:
return None
try:
return max(0.0, float(resp.json()["token"]["expiry"]))
except (ValueError, KeyError, TypeError):
return None
def note_expiry(session):
"""Remember when this login runs out, so a long run can log in again before it does."""
left = session_seconds_left(session)
session.brain_expires_at = (time.time() + left) if left else None
def log_in(session, email, password) -> bool:
"""be.authenticate, made safe for runs nobody is watching: if BRAIN asks for identity verification
and there's no keyboard to answer it, this returns False with a clear message instead of crashing."""
try:
ok = be.authenticate(session, email, password)
except (EOFError, OSError, RuntimeError):
logger.error("BRAIN wants you to confirm your identity, and nobody is here to do it. Run "
"python alpha_loop.py by hand once and open the link it prints.")
return False
if ok:
save_session(session)
note_expiry(session)
return ok
def get_authenticated_session(email: str, password: str) -> requests.Session:
session = requests.Session()
if load_session(session):
left = session_seconds_left(session)
if left is not None and left >= SESSION_MIN_SECONDS:
session.brain_expires_at = time.time() + left
logger.info("Reused saved session (%d minutes left on it) - skipped login.", left // 60)
return session
logger.info("Saved session %s - logging in fresh.",
"couldn't be confirmed" if left is None else "has expired" if not left
else f"has under {SESSION_MIN_SECONDS // 60} minutes left")
session.cookies.clear()
if not log_in(session, email, password):
raise LoginFailed("Could not log in to BRAIN - stopping before using any quota.")
return session
def keep_logged_in(session, email, password):
"""Log in again shortly before BRAIN's login runs out, instead of waiting for requests to fail."""
expires_at = getattr(session, "brain_expires_at", None)
if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)):
return
if time.time() < expires_at - SESSION_RENEW_SECONDS:
return
logger.info("BRAIN login runs out in under %d minutes - logging in again now.", SESSION_RENEW_SECONDS // 60)
session.cookies.clear()
if not log_in_again(session, email, password):
raise StopLoop("BRAIN needs re-authentication (likely identity verification) and no one is here to complete it.")
# ---- Riding out a lost connection (wifi dropped, laptop asleep) ----
OUTAGE_SECONDS = 0.0 # this run's time waiting for the connection or asleep - not counted against MAX_RUNTIME_SECONDS
def no_connection() -> StopLoop:
return StopLoop(f"No connection to BRAIN for {WAIT_FOR_CONNECTION_MINUTES} minutes - stopped cleanly; "
f"everything so far is saved. The next scheduled run carries on.")
def brain_reachable() -> bool:
"""Whether BRAIN answers at all. Any reply counts, even 'not logged in' - only a failed connection doesn't."""
try:
requests.get(be.AUTH_URL, timeout=CONNECTION_PROBE_TIMEOUT)
return True
except requests.RequestException:
return False
def wait_for_connection(what: str) -> bool:
"""If BRAIN can't be reached, check every CONNECTION_CHECK_SECONDS until it can, for up to
WAIT_FOR_CONNECTION_MINUTES. True as soon as BRAIN answers (straight away if it already does)."""
global OUTAGE_SECONDS
if brain_reachable():
return True
began = time.time()
logger.warning("Lost the connection to BRAIN %s - waiting for it to come back (checking every %ds, for up to "
"%d minutes).", what, CONNECTION_CHECK_SECONDS, WAIT_FOR_CONNECTION_MINUTES)
try:
for _ in range(max(1, WAIT_FOR_CONNECTION_MINUTES * 60 // CONNECTION_CHECK_SECONDS)):
time.sleep(CONNECTION_CHECK_SECONDS)
if brain_reachable():
logger.info("The connection is back after %d minute(s) - carrying on where the run left off.",
round((time.time() - began) / 60))
return True
logger.error("Still no connection to BRAIN after %d minutes.", WAIT_FOR_CONNECTION_MINUTES)
return False
finally:
OUTAGE_SECONDS += time.time() - began
def nap(seconds) -> float:
"""time.sleep that notices the computer sleeping through it: a pause that ran SLEEP_GAP_SECONDS or more over
was the computer asleep, and that time isn't counted against MAX_RUNTIME_SECONDS. Returns the seconds the
computer was away (0 normally)."""
global OUTAGE_SECONDS
began = time.time()
time.sleep(seconds)
away = time.time() - began - seconds
if away < SLEEP_GAP_SECONDS:
return 0.0
OUTAGE_SECONDS += away
logger.info("The computer seems to have been asleep for about %d minute(s) - carrying on.", round(away / 60))
return away
def log_in_again(session, email, password) -> bool:
"""log_in, but a login that failed only because the connection is gone waits for it to come back and tries
once more (raising StopLoop if it never does). False means BRAIN itself said no - identity verification
nobody is here to answer, or a wrong password."""
if log_in(session, email, password):
return True
if brain_reachable():
return False
if not wait_for_connection("while logging in again"):
raise no_connection()
return log_in(session, email, password)
def connect(email: str, password: str) -> requests.Session:
"""Log in for a run - first waiting out a lost connection (a laptop that just woke up, wifi that dropped),
and once more if it drops during the login itself."""
if not wait_for_connection("before logging in"):
raise LoginFailed(f"No connection to BRAIN for {WAIT_FOR_CONNECTION_MINUTES} minutes - stopping before "
f"using any quota. The next scheduled run tries again.")
try:
return get_authenticated_session(email, password)
except LoginFailed:
if brain_reachable() or not wait_for_connection("while logging in"):
raise
return get_authenticated_session(email, password)
# ---- Laptops: don't let Windows sleep in the middle of a run while it's plugged in ----
ES_CONTINUOUS, ES_SYSTEM_REQUIRED = 0x80000000, 0x00000001
AWAKE = False # whether this run has asked Windows to stay awake
def on_mains_power():
"""True when Windows says the computer is plugged in, False on battery, None when it can't tell
(or this isn't Windows)."""
try:
import ctypes
class PowerStatus(ctypes.Structure):
_fields_ = [("ACLineStatus", ctypes.c_ubyte), ("BatteryFlag", ctypes.c_ubyte),
("BatteryLifePercent", ctypes.c_ubyte), ("SystemStatusFlag", ctypes.c_ubyte),
("BatteryLifeTime", ctypes.c_ulong), ("BatteryFullLifeTime", ctypes.c_ulong)]
status = PowerStatus()
if not ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status)):
return None
return {0: False, 1: True}.get(status.ACLineStatus)
except Exception:
return None
def set_execution_state(flags: int) -> bool:
"""Windows' SetThreadExecutionState. False where there's no such thing (not Windows). Its reply is the previous
state, which can be 0 the first time, so any call that went through counts as done."""
try:
import ctypes
ctypes.windll.kernel32.SetThreadExecutionState(ctypes.c_uint(flags))
return True
except Exception:
return False
def keep_awake():
"""While a run is going and the computer is plugged in, ask Windows not to sleep (the screen can still turn
off). On battery, let it sleep as usual - the run carries on when it wakes. Called every round and every
simulation check, so plugging in or unplugging mid-run is noticed. Does nothing on other systems."""
global AWAKE
want = on_mains_power() is True
if want == AWAKE:
return
if not set_execution_state(ES_CONTINUOUS | (ES_SYSTEM_REQUIRED if want else 0)):
return
AWAKE = want
logger.info("Plugged in - keeping the computer awake until this run is done." if want else
"On battery - letting the computer sleep as usual; the run carries on when it wakes.")
def let_sleep():
"""The run is over: Windows may sleep again whenever it likes."""
global AWAKE
if AWAKE and set_execution_state(ES_CONTINUOUS):
AWAKE = False
def already_tried() -> set:
"""Every expression tried so far - each also without its add(x, 0.0001) padding, so an idea that differs from
an earlier one only by the padding counts as already tried."""
if not Path(RESULTS_FILE).exists():
return set()
with open(RESULTS_FILE, encoding="utf-8") as f:
tried = {row["expression"] for row in csv.DictReader(f)}
return tried | {strip_padding(e) for e in tried}
def note_tried(avoid: set, expression: str):
avoid.add(expression)
avoid.add(strip_padding(expression))
def safe_float(val, default=0.0):
try:
return float(val)
except (TypeError, ValueError):
return default
def brain_day(now_utc=None) -> date:
"""BRAIN's day starts at 3 AM US Eastern, not at your local midnight. Worked out from the US
daylight-saving rules directly, so it needs no time-zone database (Windows doesn't ship one)."""
now_utc = now_utc or datetime.now(timezone.utc)
def nth_sunday(month, n):
first_weekday = date(now_utc.year, month, 1).weekday() # Monday=0 ... Sunday=6
return 1 + (6 - first_weekday) % 7 + 7 * (n - 1)
dst_start = datetime(now_utc.year, 3, nth_sunday(3, 2), 7, tzinfo=timezone.utc) # 2 AM EST
dst_end = datetime(now_utc.year, 11, nth_sunday(11, 1), 6, tzinfo=timezone.utc) # 2 AM EDT
eastern = timedelta(hours=-4) if dst_start <= now_utc < dst_end else timedelta(hours=-5)
return (now_utc + eastern - timedelta(hours=3)).date()
def brain_day_ends_in(now_utc=None) -> timedelta:
"""How long until BRAIN's day rolls over (3 AM US Eastern: 07:00 UTC in summer time, 08:00 UTC in winter)."""
now_utc = now_utc or datetime.now(timezone.utc)
following = brain_day(now_utc) + timedelta(days=1)
for hour in (7, 8):
rollover = datetime(following.year, following.month, following.day, hour, tzinfo=timezone.utc)
if brain_day(rollover) == following:
return rollover - now_utc
return timedelta(days=1) # unreachable: one of the two hours is always the rollover
def submissions_today() -> int:
if not Path(SUBMISSION_LOG_FILE).exists():
return 0
try:
data = json.loads(Path(SUBMISSION_LOG_FILE).read_text(encoding="utf-8"))
except Exception:
return 0
return data.get(str(brain_day()), 0)
def record_submission():
data = {}
if Path(SUBMISSION_LOG_FILE).exists():
try:
data = json.loads(Path(SUBMISSION_LOG_FILE).read_text(encoding="utf-8"))
except Exception:
data = {}
key = str(brain_day())
data[key] = data.get(key, 0) + 1
Path(SUBMISSION_LOG_FILE).write_text(json.dumps(data), encoding="utf-8")
def as_float(val):
try:
return float(val)
except (TypeError, ValueError):
return None
def returns_note(row) -> str:
"""' returns=... drawdown=...' when BRAIN reported them, flagging an alpha that lost more in its worst
stretch than it made over the year."""
returns, drawdown = as_float(row.get("returns")), as_float(row.get("drawdown"))
if returns is None or drawdown is None:
return ""
return f" returns={row['returns']} drawdown={row['drawdown']}" + (
" RETURNS BELOW DRAWDOWN" if returns <= abs(drawdown) else "")
def turnover_note(row) -> str:
turnover = as_float(row.get("turnover"))
if turnover is None:
return ""
if turnover > TURNOVER_MAX:
return f" TURNOVER TOO HIGH (max {TURNOVER_MAX})"
if turnover < TURNOVER_MIN:
return f" TURNOVER TOO LOW (min {TURNOVER_MIN})"
return ""
def summarize_round(rows: list) -> str:
"""Condense one round's results into text the next generation prompt can use."""
if not rows:
return "This is round 1 - no prior results yet."
completed = [r for r in rows if r["status"] == "COMPLETE"]
if not completed:
return f"Last round: {len(rows)} attempts, none completed - check the error column pattern before trying similar shapes again."
ranked = sorted(completed, key=lambda r: safe_float(r["fitness"]), reverse=True)
lines = ["Last round's results, best fitness first:"]
for r in ranked:
lines.append(
f"- {r['expression']} -> sharpe={r['sharpe']} fitness={r['fitness']} "
f"turnover={r['turnover']}{turnover_note(r)}{returns_note(r)} checks={r['checks_passed']}/{r['checks_total']} "
f"submitted={r.get('submitted', 'NO')}"
+ (f" not_passing=[{r['failing_checks']}]" if r.get("failing_checks") else "")
)
failed = [r for r in rows if r["status"] != "COMPLETE"]
if failed:
lines.append(f"{len(failed)} other attempts failed outright (bad expression, timeout, etc) - avoid whatever pattern caused that.")
return "\n".join(lines)
def completed_history() -> list:
"""Every completed simulation from every run so far, read from the results CSV."""
if not Path(RESULTS_FILE).exists():
return []
with open(RESULTS_FILE, encoding="utf-8") as f:
return [r for r in csv.DictReader(f)
if r.get("status") == "COMPLETE" and as_float(r.get("fitness")) is not None]
def pool_tag(row) -> str:
"""Marks results that are already in (or headed for) your submitted pool."""
submitted = (row.get("submitted") or "").strip()
if submitted == "YES" or submitted.startswith("VERDICT_PENDING"):
return " [submitted]"
if submitted.startswith(HELD_PREFIX):
return " [queued]"
if submitted.startswith("TOO_CORRELATED"):
return " [too similar to a submitted alpha]"
return ""
# ---- Learning what doesn't work ----
_REJECTED = re.compile(r'unknown (?:variable|operator) "([^"]+)"', re.IGNORECASE)
def rejected_names() -> set:
"""Operators and fields BRAIN refused on your account ('unknown variable/operator "x"'), read
from the error column of every run so far."""
if not Path(RESULTS_FILE).exists():
return set()
with open(RESULTS_FILE, encoding="utf-8", newline="") as f:
return {name for r in csv.DictReader(f) for name in _REJECTED.findall(r.get("error") or "")}
def dead_end_fields() -> dict:
"""{field: (simulations, best fitness)} for fields tried DEAD_END_MIN_TRIES+ times without fitness ever reaching
DEAD_END_MAX_FITNESS - not counting results that failed the big-stocks check, so a field whose only good scores
came from small stocks is a dead end too (its best fitness is then None). Basic price/volume fields are exempt -
they're ingredients."""
stats = {}
for r in completed_history():
fitness = None if small_stocks_only(r) else as_float(r.get("fitness"))
for name in {n for _, _, n in data_fields_in(r["expression"])}:
tries, best = stats.get(name, (0, None))
stats[name] = (tries + 1, best if fitness is None else fitness if best is None else max(best, fitness))
return {name: (tries, best) for name, (tries, best) in stats.items()
if name not in BASIC_FIELDS and tries >= DEAD_END_MIN_TRIES
and (best is None or best < DEAD_END_MAX_FITNESS)}
def uses_any(expression: str, names) -> bool:
return bool(set(_WORD.findall(_blank_quotes(expression))) & set(names))
def avoid_note(rejected, dead: dict) -> str:
lines = []
if rejected:
lines.append("BRAIN rejected these names on your account - never use them: "
+ ", ".join(sorted(rejected)) + ". (Write comparisons with >, <, == instead.)")
if dead:
lines.append(f"These fields were tried {DEAD_END_MIN_TRIES}+ times and never reached fitness "
f"{DEAD_END_MAX_FITNESS} outside small stocks, so they're left out: "
+ ", ".join(f"{n} ({t} tries, " + ("only small-stock results" if b is None else f"best {b:.2f}")
+ ")" for n, (t, b) in sorted(dead.items())) + ".")
return ("DON'T USE:\n" + "\n".join(f"- {line}" for line in lines)) if lines else ""
# ---- Variety: spread ideas across data types ----
def field_types(fields) -> dict:
"""{field name: data type} for the signal fields the generator can use. The data type is BRAIN's
own category for the field (Option, Fundamental, Analyst...), read from data_fields_cache.json;
without that file, the dataset name fetch_fields.py keeps in [brackets] in each description.
Group fields (sector, industry...) are left out - every idea uses them for grouping."""
categories = {}
try:
cache = json.loads(Path(FIELDS_CACHE_FILE).read_text(encoding="utf-8"))
for f in cache.get("matrix_fields") or []:
cat = f.get("category")
name = (cat.get("name") or cat.get("id")) if isinstance(cat, dict) else cat
if f.get("id") and name:
categories[f["id"]] = str(name)
except (OSError, ValueError, AttributeError):
pass
found = {}
for f in fields:
name, desc = f.get("name"), (f.get("description") or "").strip()
if not name or desc.startswith("group field"):
continue
if name in categories:
found[name] = categories[name]
else:
tag = re.search(r"\[([^\[\]]+)\]$", desc)
if tag:
found[name] = tag.group(1)
return found
def expression_types(expression: str, type_map: dict) -> set:
"""The data types an expression uses. Basic price/volume data (close, returns, volume...) goes into most
formulas as an ingredient, so it only counts when the formula uses nothing else."""
words = {w for w in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", expression) if w in type_map}
basic = {type_map[b] for b in BASIC_FIELDS if b in type_map}
main = {type_map[w] for w in words if w not in BASIC_FIELDS and type_map[w] not in basic}
return main or {type_map[w] for w in words}
def crowded_types(type_map: dict) -> Counter:
"""Data types your submitted, queued or too-similar alphas are built on, with how many of each."""
counts = Counter()
if not type_map or not Path(RESULTS_FILE).exists():
return counts
with open(RESULTS_FILE, encoding="utf-8", newline="") as f:
for r in csv.DictReader(f):
if pool_tag(r):
counts.update(expression_types(r.get("expression") or "", type_map))
return counts
def per_type_cap(n: int, type_map: dict) -> int:
"""Ideas allowed per data type - loosened if there are too few types to fill a round otherwise."""
return max(MAX_IDEAS_PER_TYPE, math.ceil(n / max(1, len(set(type_map.values())))))
def type_quota(type_map: dict, crowded: Counter, asked: int) -> dict:
"""How many of the `asked` ideas to request per data type. Types your submitted or queued alphas already
use get MAX_IDEAS_PER_CROWDED_TYPE each; the rest is dealt out over the other types one at a time - types
with more fields first, none more than twice its number of fields - so the ideas can fill a round
within the per-type limits instead of piling onto one type."""
fields_per_type = Counter(type_map.values())
order = sorted(fields_per_type, key=lambda t: (t in crowded, -fields_per_type[t], t))
room = {t: MAX_IDEAS_PER_CROWDED_TYPE if t in crowded else 2 * fields_per_type[t] for t in order}
quota = Counter()
while sum(quota.values()) < asked and any(quota[t] < room[t] for t in order):
for t in order:
if quota[t] < room[t] and sum(quota.values()) < asked:
quota[t] += 1
return {t: quota[t] for t in order if quota[t]}
def variety_rule(type_map: dict, crowded: Counter, n: int, asked: int = 0) -> str:
all_types = sorted(set(type_map.values()))
if len(all_types) < 2:
return ""
lines = ["VARIETY RULE - this is what gets new alphas accepted. BRAIN won't take an alpha whose "
"self-correlation with your submitted alphas is 0.7 or more, and alphas built on the same "
"kind of data usually are. Every field above is labelled with its data type."]
if crowded:
used = ", ".join(f"{t} ({c})" for t, c in crowded.most_common())
lines.append(f"- Your submitted or queued alphas already use: {used}. At most "
f"{MAX_IDEAS_PER_CROWDED_TYPE} idea(s) this round may use each of these.")